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,107 +0,0 @@
1
- import { afterEach, expect, test } from "bun:test";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { addPlugin } from "./add-plugin.ts";
5
- import { init } from "./init.ts";
6
- import { tempDir } from "./testing/fixtures.ts";
7
-
8
- const dirs: ReturnType<typeof tempDir>[] = [];
9
- afterEach(() => {
10
- for (const dir of dirs.splice(0)) dir.done();
11
- });
12
- const project = () => {
13
- const dir = tempDir();
14
- dirs.push(dir);
15
- init({
16
- cwd: dir.path,
17
- bun: { version: "1.3.10", required: ">=1.3.0" },
18
- version: "1.2.3",
19
- });
20
- return dir;
21
- };
22
- const read = (dir: string, file: string) =>
23
- readFileSync(join(dir, file), "utf8");
24
-
25
- test("add plugin renders the plugin and its test from the hello template and lists it", () => {
26
- const dir = project();
27
- const report = addPlugin({ cwd: dir.path, name: "my-notes" });
28
- expect(report.ok).toBe(true);
29
- expect(report.changed).toEqual([
30
- "plugins/my-notes.ts",
31
- "plugins/my-notes.test.ts",
32
- "roundtable.config.ts",
33
- ]);
34
- const plugin = read(dir.path, "plugins/my-notes.ts");
35
- expect(plugin).toContain("export const myNotes = definePlugin(");
36
- expect(plugin).toContain('name: "my-notes"');
37
- expect(plugin).toContain("my_notes_greet");
38
- expect(read(dir.path, "plugins/my-notes.test.ts")).toContain(
39
- 'import { myNotes } from "./my-notes.ts"',
40
- );
41
- const config = read(dir.path, "roundtable.config.ts");
42
- expect(config).toContain('import { myNotes } from "./plugins/my-notes.ts";');
43
- expect(config).toContain("plugins: [hello, myNotes]");
44
- });
45
-
46
- test("a second plugin joins the first", () => {
47
- const dir = project();
48
- addPlugin({ cwd: dir.path, name: "alpha" });
49
- addPlugin({ cwd: dir.path, name: "beta" });
50
- expect(read(dir.path, "roundtable.config.ts")).toContain(
51
- "plugins: [hello, alpha, beta]",
52
- );
53
- });
54
-
55
- test("add plugin refuses a name that is not lowercase kebab-case, changing nothing", () => {
56
- const dir = project();
57
- const before = read(dir.path, "roundtable.config.ts");
58
- for (const name of [
59
- "MyNotes",
60
- "my_notes",
61
- "-a",
62
- "a--b",
63
- "1a",
64
- "",
65
- "a b",
66
- "../x",
67
- ]) {
68
- const report = addPlugin({ cwd: dir.path, name });
69
- expect(report.ok).toBe(false);
70
- expect(report.problems.join(" ")).toContain("is not a plugin name");
71
- }
72
- expect(read(dir.path, "roundtable.config.ts")).toBe(before);
73
- });
74
-
75
- test("add plugin refuses an existing file and leaves the config alone", () => {
76
- const dir = project();
77
- const before = read(dir.path, "roundtable.config.ts");
78
- const report = addPlugin({ cwd: dir.path, name: "hello" });
79
- expect(report.ok).toBe(false);
80
- expect(report.problems.join(" ")).toContain("plugins/hello.ts");
81
- expect(report.problems.join(" ")).toContain("already");
82
- expect(read(dir.path, "roundtable.config.ts")).toBe(before);
83
- dir.write("plugins/mine.test.ts", "// mine");
84
- expect(addPlugin({ cwd: dir.path, name: "mine" }).ok).toBe(false);
85
- expect(existsSync(join(dir.path, "plugins/mine.ts"))).toBe(false);
86
- });
87
-
88
- test("add plugin refuses a config whose plugin list it cannot find, writing no plugin file", () => {
89
- const dir = project();
90
- dir.write("roundtable.config.ts", "export default buildConfig();\n");
91
- const report = addPlugin({ cwd: dir.path, name: "extra" });
92
- expect(report.ok).toBe(false);
93
- expect(report.problems.join(" ")).toContain("cannot find the plugin list");
94
- expect(existsSync(join(dir.path, "plugins/extra.ts"))).toBe(false);
95
- expect(existsSync(join(dir.path, "plugins/extra.test.ts"))).toBe(false);
96
- expect(read(dir.path, "roundtable.config.ts")).toBe(
97
- "export default buildConfig();\n",
98
- );
99
- });
100
-
101
- test("add plugin outside a project says where it looked", () => {
102
- const dir = tempDir();
103
- dirs.push(dir);
104
- const report = addPlugin({ cwd: dir.path, name: "extra" });
105
- expect(report.ok).toBe(false);
106
- expect(report.problems.join(" ")).toContain("roundtable.config.ts is not in");
107
- });
@@ -1,276 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { Project } from "../project.ts";
3
- import type { Result } from "../report.ts";
4
- import {
5
- fakeHttp,
6
- fakePorts,
7
- tempDir,
8
- validConfig,
9
- } from "../testing/fixtures.ts";
10
- import { checkBun } from "./bun.ts";
11
- import { checkConfiguration, checkPlugins } from "./configuration.ts";
12
- import { checkEnvironment } from "./environment.ts";
13
- import { checkModelLogin } from "./model.ts";
14
- import { checkPublicUrl } from "./public-url.ts";
15
-
16
- const dirs: ReturnType<typeof tempDir>[] = [];
17
- afterEach(() => {
18
- for (const dir of dirs.splice(0)) dir.done();
19
- });
20
-
21
- /** The failure's problem and fix text, or an assertion failure when the check passed or skipped. */
22
- function failure(result: Result): string {
23
- if (result.status !== "fail")
24
- throw new Error(`expected a failure, got ${JSON.stringify(result)}`);
25
- return `${result.problem}\n${result.fix}`;
26
- }
27
-
28
- describe("Bun", () => {
29
- test("passes on a version in range", () => {
30
- expect(checkBun({ version: "1.3.10", required: ">=1.3.0" }).status).toBe(
31
- "ok",
32
- );
33
- });
34
- test("fails when missing, with the install page as the fix", () => {
35
- const text = failure(checkBun({ version: undefined, required: ">=1.3.0" }));
36
- expect(text).toContain("not installed");
37
- expect(text).toContain("https://bun.sh/docs/installation");
38
- });
39
- test("fails when too old, saying to upgrade", () => {
40
- const text = failure(checkBun({ version: "1.2.0", required: ">=1.3.0" }));
41
- expect(text).toContain("1.2.0");
42
- expect(text).toContain("bun upgrade");
43
- });
44
- });
45
-
46
- describe("environment", () => {
47
- const example =
48
- "# comment\nDISCORD_TOKEN=\nDATABASE_URL=postgres://x\n# OPTIONAL=\nOWNER_ID=\n";
49
- test("passes when every listed variable has a value", () => {
50
- const dir = tempDir();
51
- dirs.push(dir);
52
- dir.write(".env.example", example);
53
- expect(
54
- checkEnvironment(dir.path, {
55
- DISCORD_TOKEN: "t",
56
- DATABASE_URL: "d",
57
- OWNER_ID: "1",
58
- }).status,
59
- ).toBe("ok");
60
- });
61
- test("names every variable without a value, but not commented ones, and says to copy the example", () => {
62
- const dir = tempDir();
63
- dirs.push(dir);
64
- dir.write(".env.example", example);
65
- const text = failure(
66
- checkEnvironment(dir.path, { DATABASE_URL: "d", OWNER_ID: " " }),
67
- );
68
- expect(text).toContain("DISCORD_TOKEN, OWNER_ID");
69
- expect(text).not.toContain("OPTIONAL");
70
- expect(text).not.toContain("DATABASE_URL");
71
- expect(text).toContain("Copy .env.example to .env");
72
- });
73
- test("says to fill .env when it exists", () => {
74
- const dir = tempDir();
75
- dirs.push(dir);
76
- dir.write(".env.example", example);
77
- dir.write(".env", "");
78
- expect(failure(checkEnvironment(dir.path, {}))).toContain(
79
- "Fill them in .env",
80
- );
81
- });
82
- test("skips a project without an example", () => {
83
- const dir = tempDir();
84
- dirs.push(dir);
85
- expect(checkEnvironment(dir.path, {}).status).toBe("skipped");
86
- });
87
- });
88
-
89
- describe("configuration", () => {
90
- const project = (config: unknown) => new Project("/x", fakePorts(config));
91
- test("passes a configuration the schema accepts", async () => {
92
- expect((await checkConfiguration(project(validConfig))).status).toBe("ok");
93
- });
94
- test("names the failing key, and says where to fix it", async () => {
95
- const text = failure(
96
- await checkConfiguration(
97
- project({
98
- ...validConfig,
99
- discord: { ...validConfig.discord, token: "" },
100
- }),
101
- ),
102
- );
103
- expect(text).toContain("config discord.token");
104
- expect(text).toContain("roundtable.config.ts");
105
- });
106
- test("names the closest known key for one it does not know", async () => {
107
- const text = failure(
108
- await checkConfiguration(project({ ...validConfig, modle: "x" })),
109
- );
110
- expect(text).toContain('Did you mean "model"');
111
- });
112
- test("fails with the loader's message when the file cannot be loaded", async () => {
113
- const failing = new Project(
114
- "/x",
115
- fakePorts(undefined, {
116
- loadConfig: async () => {
117
- throw new Error("roundtable.config.ts is not in /x");
118
- },
119
- }),
120
- );
121
- expect(failure(await checkConfiguration(failing))).toContain(
122
- "is not in /x",
123
- );
124
- });
125
- test("fails with the assembly's own message", async () => {
126
- const failing = new Project(
127
- "/x",
128
- fakePorts(validConfig, {
129
- define: async () => {
130
- throw new Error(
131
- "config prompts.shared: cannot read ./persona/shared.md",
132
- );
133
- },
134
- }),
135
- );
136
- expect(failure(await checkConfiguration(failing))).toContain(
137
- "prompts.shared",
138
- );
139
- });
140
- });
141
-
142
- describe("plugins", () => {
143
- const plugin = (name: string) => ({ name, setup: () => ({}) });
144
- const withPlugins = (names: string[]) =>
145
- new Project(
146
- "/x",
147
- fakePorts(
148
- { ...validConfig, plugins: [plugin("hello")] },
149
- {
150
- define: async () => ({
151
- options: { logger: {} as never },
152
- plugins: names.map(plugin),
153
- }),
154
- },
155
- ),
156
- );
157
- test("passes when every name is its own and lists the operator's", async () => {
158
- const result = await checkPlugins(withPlugins(["database", "hello"]));
159
- expect(result).toEqual({ status: "ok", detail: "yours: hello" });
160
- });
161
- test("fails on a repeated name and says to rename", async () => {
162
- const text = failure(
163
- await checkPlugins(withPlugins(["database", "hello", "hello"])),
164
- );
165
- expect(text).toContain("two plugins are named hello");
166
- expect(text).toContain("Rename");
167
- });
168
- test("is skipped while the configuration is invalid", async () => {
169
- const result = await checkPlugins(new Project("/x", fakePorts({})));
170
- expect(result.status).toBe("skipped");
171
- });
172
- });
173
-
174
- describe("model login", () => {
175
- const login = (source: string | undefined, seen: string[] = []) =>
176
- fakePorts(validConfig, {
177
- login: async (dir, provider) => {
178
- seen.push(`${dir} ${provider}`);
179
- return source;
180
- },
181
- });
182
- test("passes when the provider has a login and says where it comes from", async () => {
183
- const seen: string[] = [];
184
- const result = await checkModelLogin(
185
- new Project("/x", login("ANTHROPIC_API_KEY", seen)),
186
- login("ANTHROPIC_API_KEY", seen),
187
- );
188
- expect(result).toEqual({
189
- status: "ok",
190
- detail: "anthropic login from ANTHROPIC_API_KEY",
191
- });
192
- expect(seen).toEqual(["/data/pi anthropic"]);
193
- });
194
- test("fails when there is none, naming the provider, the model, and the ways to log in", async () => {
195
- const ports = login(undefined);
196
- const text = failure(
197
- await checkModelLogin(new Project("/x", ports), ports),
198
- );
199
- expect(text).toContain("no login for anthropic");
200
- expect(text).toContain("anthropic/claude-sonnet-5-5");
201
- expect(text).toContain("ANTHROPIC_API_KEY");
202
- expect(text).toContain("/data/pi/auth.json");
203
- });
204
- test("fails on a model that is not <provider>/<id>", async () => {
205
- const ports = fakePorts({ ...validConfig, model: "sonnet" });
206
- expect(
207
- failure(await checkModelLogin(new Project("/x", ports), ports)),
208
- ).toContain("<provider>/<id>");
209
- });
210
- test("uses the configured agent directory", async () => {
211
- const seen: string[] = [];
212
- const ports = fakePorts(
213
- { ...validConfig, agentDir: "/agents" },
214
- {
215
- login: async (dir) => {
216
- seen.push(dir);
217
- return "x";
218
- },
219
- },
220
- );
221
- await checkModelLogin(new Project("/x", ports), ports);
222
- expect(seen).toEqual(["/agents"]);
223
- });
224
- });
225
-
226
- describe("public URL", () => {
227
- const project = (url: unknown) =>
228
- new Project("/x", fakePorts({ ...validConfig, http: { publicUrl: url } }));
229
- test("passes a well-formed address without asking the network", async () => {
230
- const http = fakeHttp({});
231
- const result = await checkPublicUrl(
232
- project("https://bot.example.test"),
233
- http,
234
- false,
235
- );
236
- expect(result.status).toBe("ok");
237
- expect(http.requests).toEqual([]);
238
- });
239
- test("fails on something that is not a URL, showing an example", async () => {
240
- const text = failure(
241
- await checkPublicUrl(project("bot.example.test"), fakeHttp({}), false),
242
- );
243
- expect(text).toContain("not a URL");
244
- expect(text).toContain("https://bot.example.com");
245
- });
246
- test("fails on a scheme Discord cannot fetch from", async () => {
247
- expect(
248
- failure(
249
- await checkPublicUrl(
250
- project("ftp://bot.example.test"),
251
- fakeHttp({}),
252
- false,
253
- ),
254
- ),
255
- ).toContain("http(s)");
256
- });
257
- test("with reachable, passes on any answer and fails when nothing answers", async () => {
258
- const answering = fakeHttp({ "/": { status: 404, body: undefined } });
259
- expect(
260
- await checkPublicUrl(
261
- project("https://bot.example.test"),
262
- answering,
263
- true,
264
- ),
265
- ).toEqual({
266
- status: "ok",
267
- detail: "https://bot.example.test answered 404",
268
- });
269
- const down = fakeHttp({ "/": new Error("connection refused") });
270
- const text = failure(
271
- await checkPublicUrl(project("https://bot.example.test"), down, true),
272
- );
273
- expect(text).toContain("connection refused");
274
- expect(text).toContain("roundtable start");
275
- });
276
- });
@@ -1,117 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import type { Migration } from "../../core/db/migrations.ts";
3
- import { MigrationError } from "../../core/errors.ts";
4
- import { describeDb, testDatabaseUrl } from "../../core/testing/database.ts";
5
- import { Project } from "../project.ts";
6
- import { fakePorts, validConfig } from "../testing/fixtures.ts";
7
- import { checkDatabase, type DatabasePort, postgres } from "./database.ts";
8
-
9
- const migration: Migration = { name: "doctor-probe", up: async () => {} };
10
- const withMigration = (name = "probe") =>
11
- new Project(
12
- "/x",
13
- fakePorts(validConfig, {
14
- define: async () => ({
15
- options: { logger: {} as never },
16
- plugins: [{ name, setup: () => ({}), migrations: [migration] }],
17
- }),
18
- }),
19
- );
20
-
21
- test("passes when the database answers and the migrations run, without printing the password", async () => {
22
- const asked: unknown[] = [];
23
- const port: DatabasePort = {
24
- check: async (url, migrations) => void asked.push([url, migrations]),
25
- };
26
- const result = await checkDatabase(withMigration(), port);
27
- expect(result.status).toBe("ok");
28
- if (result.status === "ok") {
29
- expect(result.detail).toContain("db.example.test:5432/bot");
30
- expect(result.detail).not.toContain("secret");
31
- }
32
- expect(asked).toEqual([[validConfig.database.url, [migration]]]);
33
- });
34
-
35
- test("fails when the database is unreachable, naming it and saying how to start one", async () => {
36
- const port: DatabasePort = {
37
- check: async () => {
38
- throw new Error("connection refused");
39
- },
40
- };
41
- const result = await checkDatabase(withMigration(), port);
42
- expect(result.status).toBe("fail");
43
- if (result.status === "fail") {
44
- expect(result.problem).toContain("db.example.test:5432/bot");
45
- expect(result.problem).toContain("connection refused");
46
- expect(result.problem).not.toContain("secret");
47
- expect(result.fix).toContain("docker compose up -d");
48
- }
49
- });
50
-
51
- test("fails when a migration fails, naming it", async () => {
52
- const port: DatabasePort = {
53
- check: async () => {
54
- throw new MigrationError("owner-memory", new Error("permission denied"));
55
- },
56
- };
57
- const result = await checkDatabase(withMigration(), port);
58
- expect(result.status).toBe("fail");
59
- if (result.status === "fail") {
60
- expect(result.problem).toContain("migration owner-memory failed");
61
- expect(result.problem).toContain("permission denied");
62
- }
63
- });
64
-
65
- test("still tests reachability from the raw url while the configuration is invalid, and skips without a url", async () => {
66
- const asked: unknown[] = [];
67
- const port: DatabasePort = {
68
- check: async (url, migrations) => void asked.push([url, migrations]),
69
- };
70
- const invalid = new Project(
71
- "/x",
72
- fakePorts({ database: { url: "postgres://h/d" } }),
73
- );
74
- const result = await checkDatabase(invalid, port);
75
- expect(result.status).toBe("ok");
76
- expect(asked).toEqual([["postgres://h/d", undefined]]);
77
- const none = await checkDatabase(new Project("/x", fakePorts({})), port);
78
- expect(none.status).toBe("skipped");
79
- });
80
-
81
- describeDb("against PostgreSQL", () => {
82
- test("runs the migrations inside a rolled-back transaction, including ones that open their own", async () => {
83
- const table = `doctor_probe_${Date.now()}`;
84
- const run: Migration = {
85
- name: "creates",
86
- up: async (sql) => {
87
- await sql`create table ${sql(table)} (id int)`;
88
- await sql.begin(async (inner) => {
89
- await inner`insert into ${inner(table)} values (1)`;
90
- });
91
- },
92
- };
93
- await postgres.check(testDatabaseUrl, [run]);
94
- const pool = new Bun.SQL(testDatabaseUrl, { max: 1 });
95
- try {
96
- const [found] = await pool`select to_regclass(${table}) as name`;
97
- expect(found?.name).toBeNull();
98
- } finally {
99
- await pool.close();
100
- }
101
- });
102
-
103
- test("reports a failing migration as a MigrationError and an unreachable server as an error", async () => {
104
- const broken: Migration = {
105
- name: "broken",
106
- up: async (sql) => {
107
- await sql`select * from no_such_table_anywhere`;
108
- },
109
- };
110
- await expect(
111
- postgres.check(testDatabaseUrl, [broken]),
112
- ).rejects.toBeInstanceOf(MigrationError);
113
- await expect(
114
- postgres.check("postgres://nobody@127.0.0.1:1/none", undefined),
115
- ).rejects.toThrow();
116
- });
117
- });
@@ -1,200 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { PermissionFlagsBits } from "discord.js";
3
- import { INVITE_PERMISSIONS } from "../discord-api.ts";
4
- import { Project } from "../project.ts";
5
- import type { Result } from "../report.ts";
6
- import {
7
- BOT_ID,
8
- fakeHttp,
9
- fakePorts,
10
- healthyDiscord,
11
- validConfig,
12
- } from "../testing/fixtures.ts";
13
- import {
14
- checkChannel,
15
- checkGuild,
16
- checkIntents,
17
- checkToken,
18
- Discord,
19
- } from "./discord.ts";
20
-
21
- const guild = validConfig.discord.guild;
22
- const channel = validConfig.discord.entryChannel;
23
- const project = (config: unknown = validConfig) =>
24
- new Project("/x", fakePorts(config));
25
-
26
- function failure(result: Result): string {
27
- if (result.status !== "fail")
28
- throw new Error(`expected a failure, got ${JSON.stringify(result)}`);
29
- return `${result.problem}\n${result.fix}`;
30
- }
31
-
32
- const all = INVITE_PERMISSIONS;
33
- const bit = (name: keyof typeof PermissionFlagsBits): bigint =>
34
- PermissionFlagsBits[name];
35
- const without = (name: keyof typeof PermissionFlagsBits): bigint =>
36
- all & ~bit(name);
37
-
38
- describe("Discord token", () => {
39
- test("passes and names the bot", async () => {
40
- const http = fakeHttp(healthyDiscord(all));
41
- const result = await checkToken(new Discord(project(), http));
42
- expect(result).toEqual({ status: "ok", detail: "the bot is Bot" });
43
- });
44
- test("fails on a rejected token with where to reset it", async () => {
45
- const http = fakeHttp({ "/users/@me": { status: 401, body: {} } });
46
- const text = failure(await checkToken(new Discord(project(), http)));
47
- expect(text).toContain("rejected the bot token");
48
- expect(text).toContain("DISCORD_TOKEN");
49
- expect(text).toContain("discord.com/developers");
50
- });
51
- test("fails when Discord cannot be reached, without a stack trace", async () => {
52
- const http = fakeHttp({ "/users/@me": new Error("getaddrinfo ENOTFOUND") });
53
- const text = failure(await checkToken(new Discord(project(), http)));
54
- expect(text).toContain("cannot reach discord.com: getaddrinfo ENOTFOUND");
55
- expect(text).toContain("network");
56
- });
57
- test("skips without a token, and sends the token as the bot's authorization", async () => {
58
- const none = project({
59
- ...validConfig,
60
- discord: { ...validConfig.discord, token: "" },
61
- });
62
- expect((await checkToken(new Discord(none, fakeHttp({})))).status).toBe(
63
- "skipped",
64
- );
65
- let seen: Record<string, string> | undefined;
66
- await checkToken(
67
- new Discord(project(), {
68
- get: async (_url, headers) => {
69
- seen = headers;
70
- return { status: 200, body: { id: BOT_ID } };
71
- },
72
- }),
73
- );
74
- expect(seen).toEqual({ Authorization: "Bot bot-token" });
75
- });
76
- test("asks Discord once for who the bot is however many checks need it", async () => {
77
- const http = fakeHttp(healthyDiscord(all));
78
- const discord = new Discord(project(), http);
79
- await checkToken(discord);
80
- await checkGuild(project(), discord);
81
- await checkChannel(project(), discord);
82
- expect(http.requests.filter((path) => path === "/users/@me")).toHaveLength(
83
- 1,
84
- );
85
- });
86
- });
87
-
88
- describe("Discord guild", () => {
89
- test("passes when the bot is in the guild", async () => {
90
- const http = fakeHttp(healthyDiscord(all));
91
- const result = await checkGuild(project(), new Discord(project(), http));
92
- expect(result).toEqual({
93
- status: "ok",
94
- detail: "the bot is in Test Guild",
95
- });
96
- });
97
- test("fails when it is not, with an invitation that asks for what the bot needs", async () => {
98
- const http = fakeHttp({
99
- ...healthyDiscord(all),
100
- [`/guilds/${guild}`]: { status: 404, body: { code: 10004 } },
101
- });
102
- const text = failure(
103
- await checkGuild(project(), new Discord(project(), http)),
104
- );
105
- expect(text).toContain(`not in the guild ${guild}`);
106
- expect(text).toContain(`client_id=${BOT_ID}`);
107
- expect(text).toContain(`permissions=${INVITE_PERMISSIONS}`);
108
- });
109
- test("is skipped when the token failed", async () => {
110
- const http = fakeHttp({ "/users/@me": { status: 401, body: {} } });
111
- expect(
112
- (await checkGuild(project(), new Discord(project(), http))).status,
113
- ).toBe("skipped");
114
- });
115
- });
116
-
117
- describe("Discord intents", () => {
118
- test("passes with the Message Content intent, granted or limited", async () => {
119
- for (const flags of [1 << 18, 1 << 19]) {
120
- const http = fakeHttp({
121
- ...healthyDiscord(all),
122
- "/applications/@me": { status: 200, body: { flags } },
123
- });
124
- expect((await checkIntents(new Discord(project(), http))).status).toBe(
125
- "ok",
126
- );
127
- }
128
- });
129
- test("fails without it, saying where to turn it on", async () => {
130
- const http = fakeHttp({
131
- ...healthyDiscord(all),
132
- "/applications/@me": { status: 200, body: { flags: 0 } },
133
- });
134
- const text = failure(await checkIntents(new Discord(project(), http)));
135
- expect(text).toContain("Message Content intent is off");
136
- expect(text).toContain("Bot in https://discord.com/developers");
137
- });
138
- });
139
-
140
- describe("Discord channel", () => {
141
- const check = (routes: Record<string, never> | object) =>
142
- checkChannel(project(), new Discord(project(), fakeHttp(routes as never)));
143
- test("passes when the bot has every permission", async () => {
144
- expect((await check(healthyDiscord(all))).status).toBe("ok");
145
- });
146
- test("fails naming the permission the bot lacks, Pin Messages included, and why it is needed", async () => {
147
- const text = failure(await check(healthyDiscord(without("PinMessages"))));
148
- expect(text).toContain("lacks PinMessages");
149
- expect(text).toContain("pin the dashboard");
150
- });
151
- test("a channel overwrite that denies a permission counts, and an allow restores it", async () => {
152
- const pin = bit("PinMessages");
153
- const denied = healthyDiscord(all);
154
- denied[`/channels/${channel}`] = {
155
- status: 200,
156
- body: {
157
- guild_id: guild,
158
- permission_overwrites: [
159
- { id: "700000000000000001", type: 0, allow: "0", deny: String(pin) },
160
- ],
161
- },
162
- };
163
- expect(failure(await check(denied))).toContain("PinMessages");
164
- const restored = healthyDiscord(all);
165
- restored[`/channels/${channel}`] = {
166
- status: 200,
167
- body: {
168
- guild_id: guild,
169
- permission_overwrites: [
170
- { id: guild, type: 0, allow: "0", deny: String(pin) },
171
- { id: BOT_ID, type: 1, allow: String(pin), deny: "0" },
172
- ],
173
- },
174
- };
175
- expect((await check(restored)).status).toBe("ok");
176
- });
177
- test("the owner of the guild and an administrator have every permission", async () => {
178
- const administrator = 1n << 3n;
179
- expect((await check(healthyDiscord(administrator))).status).toBe("ok");
180
- });
181
- test("fails when the channel does not exist or is in another guild", async () => {
182
- const missing = healthyDiscord(all);
183
- missing[`/channels/${channel}`] = { status: 404, body: {} };
184
- expect(failure(await check(missing))).toContain("DISCORD_ENTRY_CHANNEL_ID");
185
- const elsewhere = healthyDiscord(all);
186
- elsewhere[`/channels/${channel}`] = {
187
- status: 200,
188
- body: { guild_id: "999", permission_overwrites: [] },
189
- };
190
- expect(failure(await check(elsewhere))).toContain("another guild");
191
- });
192
- test("is skipped without the channel or guild value", async () => {
193
- const empty = project({
194
- ...validConfig,
195
- discord: { ...validConfig.discord, entryChannel: "" },
196
- });
197
- const result = await checkChannel(empty, new Discord(empty, fakeHttp({})));
198
- expect(result.status).toBe("skipped");
199
- });
200
- });