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
package/src/testing.ts CHANGED
@@ -1,12 +1,41 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
5
  import type { SQL } from "bun";
3
- import type { ConversationPort } from "./core/contract/channels.ts";
6
+ import { agentClaim, OWNER_TARGET } from "./core/agents/agent-claim.ts";
7
+ import { DISCORD } from "./core/builtin/discord.ts";
8
+ import type {
9
+ ConversationPort,
10
+ InboundMessage,
11
+ } from "./core/contract/channels.ts";
4
12
  import type { Providers } from "./core/contract/providers.ts";
13
+ import type {
14
+ AgentRuntime,
15
+ HeldActionStore,
16
+ RuntimeDeps,
17
+ } from "./core/contract/runtime.ts";
18
+ import type { ServiceKey } from "./core/contract/services.ts";
19
+ import type { ChatSurface, SurfacePort } from "./core/contract/surface.ts";
20
+ import { CommandCollection } from "./core/discord/command-collection.ts";
21
+ import type { ComposedCommands } from "./core/discord/compose-commands.ts";
22
+ import type {
23
+ CommandGuard,
24
+ CommandRegistrar,
25
+ InteractionContribution,
26
+ } from "./core/discord/interaction-module.ts";
27
+ import { OwnerGuard, ownerRootCommand } from "./core/discord/owner-command.ts";
28
+ import type { PendingConfirmation } from "./core/domain/conversation.ts";
5
29
  import { NotLinkedError, PluginError } from "./core/errors.ts";
6
30
  import { EventBus } from "./core/events.ts";
31
+ import type { HoldCheck } from "./core/holds.ts";
32
+ import type { OwnerIdentity } from "./core/identity.ts";
33
+ import { ConfirmationJudge } from "./core/judging/confirmation-judge.ts";
7
34
  import { silentLogger } from "./core/log.ts";
35
+ import { ConversationBackgroundTurns } from "./core/modules/background/background-turns.ts";
8
36
  import type {
9
37
  Contribution,
38
+ HostEnv,
10
39
  LinkedSessions,
11
40
  PluginContext,
12
41
  RoundtablePlugin,
@@ -18,15 +47,131 @@ import {
18
47
  linkSessions,
19
48
  } from "./core/registry/contributions.ts";
20
49
  import { resolveProviders } from "./core/registry/providers.ts";
50
+ import { ServiceRegistry } from "./core/registry/services.ts";
21
51
  import { ChannelQueue } from "./core/routing/channel-queue.ts";
22
- import { CoreRegistry } from "./core/services.ts";
23
- import type { SessionContext } from "./core/sessions.ts";
24
- import type { Speaker } from "./core/speakers.ts";
52
+ import { ChannelRouter } from "./core/routing/channel-router.ts";
53
+ import {
54
+ type ConversationTurns,
55
+ conversationTurns,
56
+ } from "./core/routing/conversation-turns.ts";
57
+ import { surfacePort } from "./core/routing/surface-port.ts";
58
+ import { AGENTS, type AgentServer, BACKGROUND_TURNS } from "./core/services.ts";
59
+ import type { ChannelKey, SessionContext } from "./core/sessions.ts";
60
+ import { type Speaker, speakerPolicy } from "./core/speakers.ts";
25
61
  import { type ToolTierTable, toolTiers } from "./core/tool-tiers.ts";
26
62
 
63
+ // Fixtures for plugin tests.
64
+
65
+ export { silentLogger } from "./core/log.ts";
66
+
67
+ export type { TestStore } from "./core/testing/database.ts";
68
+ export {
69
+ describeDb,
70
+ openTestStore,
71
+ TEST_GUILD,
72
+ testDatabaseUrl,
73
+ } from "./core/testing/database.ts";
74
+ export { eagerText, useEagerCatalog } from "./core/testing/eager-catalog.ts";
75
+ export type { TestLocale } from "./core/testing/locale.ts";
76
+ export { useTestLocale } from "./core/testing/locale.ts";
77
+
78
+ export { OWNER_SPEAKER } from "./core/testing/owner.ts";
79
+ export type { TestHost, TestHostOptions } from "./core/testing/test-host.ts";
80
+ export { testHost } from "./core/testing/test-host.ts";
81
+ export type { FakeThreadHost } from "./core/testing/thread-host.ts";
82
+ export { fakeThreads } from "./core/testing/thread-host.ts";
83
+
84
+ /**
85
+ * A service the plugin under test reads, with the members the test gives it, made by
86
+ * `servicePair(KEY, { ... })`. Reading a member the test did not give throws a PluginError that
87
+ * names it.
88
+ */
89
+ export interface ServicePair {
90
+ readonly key: ServiceKey<unknown>;
91
+ readonly given: object;
92
+ }
93
+
94
+ /** Pairs a service key with the members the test gives it, for the `services` option of `testPlugin`. */
95
+ export function servicePair<T extends object>(
96
+ key: ServiceKey<T>,
97
+ given: Partial<T>,
98
+ ): ServicePair {
99
+ return { key, given };
100
+ }
101
+
102
+ /** A stand-in for the Discord plugin's `DISCORD` service, made by `fakeDiscord()`. */
103
+ export interface FakeDiscord {
104
+ /** Give this to `testPlugin`'s `services` option: `{ services: [discord.service] }`. */
105
+ readonly service: ServicePair;
106
+ /** What `DISCORD.commands` is to the plugin: the real registrar, which refuses what the Discord plugin refuses. */
107
+ readonly commands: CommandRegistrar;
108
+ /** What `DISCORD.guard` is: the owner, `owner` unless the test gave another id, serving the root command. */
109
+ readonly guard: CommandGuard;
110
+ /** Every contribution the plugin added, in the order it added them. */
111
+ added(): readonly InteractionContribution[];
112
+ /** The tree the Discord plugin would register, composed under the root command the fake was made with. */
113
+ compose(): ComposedCommands;
114
+ }
115
+
116
+ /**
117
+ * A `DISCORD` service for a plugin that adds slash commands, without a Discord connection: the
118
+ * plugin's `commands.add` calls are recorded, and `compose()` shows the tree Discord would get.
119
+ * `rootCommand` is the root command's name, `roundtable` by default. Only `commands` and `guard` are given; reading another member throws.
120
+ */
121
+ export function fakeDiscord(
122
+ options: { ownerId?: string; rootCommand?: string } = {},
123
+ ): FakeDiscord {
124
+ const collection = new CommandCollection();
125
+ const { rootCommand = "roundtable" } = options;
126
+ const guard = new OwnerGuard(
127
+ options.ownerId ?? "owner",
128
+ silentLogger(),
129
+ rootCommand,
130
+ );
131
+ return {
132
+ service: servicePair(DISCORD, { commands: collection.registrar, guard }),
133
+ commands: collection.registrar,
134
+ guard,
135
+ added: () => collection.added(),
136
+ compose: () => collection.compose(ownerRootCommand(rootCommand)),
137
+ };
138
+ }
139
+
27
140
  export interface TestPluginOptions {
141
+ /** The host environment the plugin sees; default en and UTC. */
142
+ env?: Partial<HostEnv>;
143
+ /**
144
+ * The owner the runtime's dependencies and the agent server's claim know; default `owner`,
145
+ * named Owner, who is addressed as they.
146
+ */
147
+ owner?: { id: string; name: string; pronouns?: OwnerIdentity["pronouns"] };
28
148
  database?: SQL;
29
149
  providers?: Partial<Providers>;
150
+ /**
151
+ * Chat surfaces besides the plugin's own, such as a fake one: they are in `context.surfaces`,
152
+ * started with the plugin's services (their messages reach `conversations`), and stopped last.
153
+ */
154
+ surfaces?: readonly ChatSurface[];
155
+ /**
156
+ * What the plugin reads from `context.services`, one `servicePair(KEY, { ... })` per service.
157
+ * `servicePair(AGENTS, { runtime })` is the runtime that `context.turns` runs turns on; without
158
+ * it, a plugin that fills the `runtime` slot gets the runtime its provider builds. A service
159
+ * not given here reads as absent to `find`, and `get` names this option, except for what the
160
+ * host supplies anyway: `BACKGROUND_TURNS`, the real background turns over the test's router,
161
+ * and, once `AGENTS` is given, its `approvals` (the real confirmation judge over
162
+ * `providers.judge`, when the test gives a judge). Giving `AGENTS` a `team` adds the agent
163
+ * server's own claim to the router, answering as the host's would for the `owner`.
164
+ */
165
+ services?: readonly ServicePair[];
166
+ /**
167
+ * The plugin's conversations: by default a router over the plugin's own `channels` claims, so a
168
+ * message a surface delivers reaches them. Methods given here replace the router's.
169
+ */
170
+ conversations?: Partial<ConversationPort>;
171
+ /** Replaces `context.turns`, which by default runs turns over the runtime and the surfaces. */
172
+ turns?: ConversationTurns;
173
+ /** How long the router holds a bare forward for the message that follows it; the host option `conversations.forwardJoinMs`. */
174
+ forwardJoinMs?: number;
30
175
  }
31
176
 
32
177
  export interface RecordedEvent {
@@ -36,13 +181,23 @@ export interface RecordedEvent {
36
181
 
37
182
  export interface TestPluginResult {
38
183
  contribution: Contribution;
184
+ /** The hold rules the plugin contributed, chained as the host links them: the description of a call that must be approved first, or undefined. */
185
+ holds: HoldCheck;
186
+ /** What the plugin sees as `context.conversations`, for a test to drive its claims. */
187
+ conversations: ConversationPort;
188
+ /** What the plugin sees as `context.turns`. */
189
+ turns: ConversationTurns;
190
+ /** What the plugin sees as `context.surfaces`: its own surfaces and the injected ones. */
191
+ surfaces: SurfacePort;
192
+ /** The runtime the plugin's `runtime` provider built, given stand-in dependencies; undefined when it fills no such slot. */
193
+ runtime: AgentRuntime | undefined;
39
194
  tools: readonly string[];
40
195
  tiers: ToolTierTable;
41
196
  events: RecordedEvent[];
42
197
  runTool(
43
198
  name: string,
44
199
  args: Record<string, unknown>,
45
- options?: { speaker?: Speaker },
200
+ options?: { speaker?: Speaker; channel?: ChannelKey },
46
201
  ): Promise<string>;
47
202
  stop(): Promise<void>;
48
203
  }
@@ -53,6 +208,10 @@ const NOT_LINKED = {
53
208
  "conversations are linked once every plugin is set up. Use them from a service's start or from a handler, not during setup.",
54
209
  sessions:
55
210
  "session parts are linked once every plugin is set up. Call sessions() from a service's start or from a handler, not during setup.",
211
+ surfaces:
212
+ "chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
213
+ turns:
214
+ "conversation turns are linked once every plugin is set up. Use turns from a service's start or from a handler, not during setup.",
56
215
  dashboard:
57
216
  "dashboard lines are linked once every plugin is set up. Call dashboard() from a service's start or from a handler, not during setup.",
58
217
  };
@@ -61,77 +220,274 @@ function unlinked(part: keyof typeof NOT_LINKED): never {
61
220
  throw new NotLinkedError(NOT_LINKED[part]);
62
221
  }
63
222
 
223
+ /** Members a probe may ask of any object: a test's partial service is not refused for them. */
224
+ const PROBED = new Set(["then", "toJSON", "asymmetricMatch"]);
225
+
226
+ /** A service the test gave only some members of: reading another names the option that adds it. */
227
+ function partialService(key: ServiceKey<unknown>, given: object): object {
228
+ return new Proxy(given, {
229
+ get(target, member, receiver) {
230
+ if (member in target || typeof member === "symbol" || PROBED.has(member))
231
+ return Reflect.get(target, member, receiver);
232
+ throw new PluginError(
233
+ `testPlugin gave service ${key.id} no "${member}". Give it in the services option: testPlugin(plugin, { services: [servicePair(KEY, { ${member}: ... })] }).`,
234
+ );
235
+ },
236
+ });
237
+ }
238
+
239
+ /** Held actions kept in memory, for the runtime a plugin's provider builds under test. */
240
+ function memoryHeldActions(): HeldActionStore {
241
+ const held = new Map<ChannelKey, PendingConfirmation>();
242
+ return {
243
+ load: async (conversation) => held.get(conversation),
244
+ save: async (conversation, actions) => {
245
+ if (actions) held.set(conversation, actions);
246
+ else held.delete(conversation);
247
+ },
248
+ };
249
+ }
250
+
64
251
  /** Build one plugin without Discord or PostgreSQL, retaining the host's validation and tier logic. */
252
+ // pi-lens-ignore: high-complexity, large-function — one host's context and lifecycle, mirrored end to end
65
253
  export async function testPlugin(
66
254
  plugin: RoundtablePlugin,
67
255
  options: TestPluginOptions = {},
68
256
  ): Promise<TestPluginResult> {
69
257
  const logger = silentLogger();
258
+ const owner = {
259
+ id: "owner",
260
+ name: "Owner",
261
+ pronouns: { subject: "they", object: "them", possessive: "their" },
262
+ ...options.owner,
263
+ };
70
264
  const tiers = toolTiers();
71
265
  const bus = new EventBus(logger);
72
266
  const events: RecordedEvent[] = [];
73
- const core = new CoreRegistry();
267
+ // The plugin under test is the only one registered, so a service it reads comes from the test.
268
+ const services = new ServiceRegistry(
269
+ [plugin],
270
+ "testPlugin has no built-in plugins: give it in the services option, testPlugin(plugin, { services: [servicePair(KEY, { ... })] }).",
271
+ );
272
+ const given = new Map(
273
+ (options.services ?? []).map((pair) => [pair.key.id, pair] as const),
274
+ );
275
+ const agents = given.get(AGENTS.id);
276
+ const resolved = resolveProviders([plugin]);
74
277
  let linked: LinkedSessions | undefined;
278
+ let router: ChannelRouter | undefined;
279
+ const queue = new ChannelQueue();
75
280
  const conversations: ConversationPort = {
76
- handle: async () => unlinked("conversations"),
77
- background: async () => unlinked("conversations"),
78
- startFresh: async () => unlinked("conversations"),
79
- deleteConversation: async () => unlinked("conversations"),
80
- stop: () => unlinked("conversations"),
81
- postsInPlace: () => unlinked("conversations"),
281
+ handle: async (message) =>
282
+ (router ?? unlinked("conversations")).handle(message),
283
+ background: async (turn) =>
284
+ (router ?? unlinked("conversations")).background(turn),
285
+ target: (name) => (router ?? unlinked("conversations")).target(name),
286
+ startFresh: async (channel) =>
287
+ (router ?? unlinked("conversations")).startFresh(channel),
288
+ deleteConversation: async (channel) =>
289
+ (router ?? unlinked("conversations")).deleteConversation(channel),
290
+ stop: (channel) => (router ?? unlinked("conversations")).stop(channel),
291
+ postsInPlace: (channel) =>
292
+ (router ?? unlinked("conversations")).postsInPlace(channel),
293
+ ...options.conversations,
82
294
  };
83
- const context: PluginContext = {
295
+ const surfaces = surfacePort(() =>
296
+ linked
297
+ ? [...registry.surfaces, ...(options.surfaces ?? [])]
298
+ : unlinked("surfaces"),
299
+ );
300
+ const sink = {
301
+ turnStarted: (turn: TurnEvent) => {
302
+ events.push({ name: "turnStarted", turn });
303
+ bus.sink.turnStarted(turn);
304
+ },
305
+ turnEnded: (turn: TurnEndEvent) => {
306
+ events.push({ name: "turnEnded", turn });
307
+ bus.sink.turnEnded(turn);
308
+ },
309
+ changed: () => {
310
+ events.push({ name: "changed" });
311
+ bus.sink.changed();
312
+ },
313
+ };
314
+ const turns: ConversationTurns =
315
+ options.turns ??
316
+ conversationTurns({
317
+ linked: () => {
318
+ if (!linked) unlinked("turns");
319
+ },
320
+ runtime: () => services.get(AGENTS).runtime,
321
+ surfaces,
322
+ events: sink,
323
+ selection: () => (linked ?? unlinked("sessions")).agentSelection(),
324
+ logger,
325
+ });
326
+ const providers = {
327
+ ...resolved,
328
+ ...options.providers,
329
+ filled: new Set([
330
+ ...resolved.filled,
331
+ ...(Object.keys(options.providers ?? {}) as (keyof Providers)[]),
332
+ ]),
333
+ };
334
+ const env: HostEnv = {
335
+ locale: "en",
336
+ timeZone: "UTC",
337
+ now: () => new Date(),
338
+ ...options.env,
339
+ };
340
+ // What the agent server does with the slot: the provider's runtime serves `context.turns`.
341
+ let runtime: AgentRuntime | undefined;
342
+ if (providers.filled.has("runtime")) {
343
+ const deps: RuntimeDeps = {
344
+ logger,
345
+ env,
346
+ owner: { id: owner.id, name: owner.name },
347
+ sessions: () => linked ?? unlinked("sessions"),
348
+ toolTiers: tiers,
349
+ prompts: (channel, speaker) => surfaces.prompts(channel, speaker),
350
+ agents: {
351
+ workDir: tmpdir(),
352
+ skills: () => [],
353
+ modelOf: () => ({ model: "test/model", thinking: "off" }),
354
+ turnChannel: (scope) => scope.home,
355
+ },
356
+ confirmations: memoryHeldActions(),
357
+ judge: providers.judge,
358
+ };
359
+ runtime = providers.runtime(deps);
360
+ // The provider's runtime serves `context.turns` unless the test gave another.
361
+ if (!agents || !("runtime" in agents.given))
362
+ given.set(AGENTS.id, {
363
+ key: AGENTS,
364
+ given: { ...agents?.given, runtime } satisfies Partial<AgentServer>,
365
+ });
366
+ }
367
+ // Once the agent server is there, it judges the owner's replies to held actions as the host's does.
368
+ const server = given.get(AGENTS.id);
369
+ if (server && providers.filled.has("judge") && !("approvals" in server.given))
370
+ given.set(AGENTS.id, {
371
+ key: AGENTS,
372
+ given: {
373
+ ...server.given,
374
+ approvals: new ConfirmationJudge({
375
+ judge: providers.judge,
376
+ threshold: 0.6,
377
+ logger,
378
+ }),
379
+ } satisfies Partial<AgentServer>,
380
+ });
381
+ for (const { key, given: members } of given.values())
382
+ services.preset(key, partialService(key, members));
383
+ // The turns nobody wrote run through the same router, unless the test gave others or the plugin provides them.
384
+ if (
385
+ !given.has(BACKGROUND_TURNS.id) &&
386
+ !plugin.provides?.some((key) => key.id === BACKGROUND_TURNS.id)
387
+ )
388
+ services.preset(
389
+ BACKGROUND_TURNS,
390
+ new ConversationBackgroundTurns({
391
+ conversations,
392
+ system: { id: "assistant", name: "Assistant" },
393
+ logger,
394
+ }),
395
+ );
396
+ const context: Omit<PluginContext, "services"> = {
84
397
  logger,
398
+ env,
85
399
  sessions: () => linked ?? unlinked("sessions"),
86
- queue: new ChannelQueue(),
400
+ queue,
87
401
  toolTiers: tiers,
88
- events: {
89
- turnStarted: (turn) => {
90
- events.push({ name: "turnStarted", turn });
91
- bus.sink.turnStarted(turn);
92
- },
93
- turnEnded: (turn) => {
94
- events.push({ name: "turnEnded", turn });
95
- bus.sink.turnEnded(turn);
96
- },
97
- changed: () => {
98
- events.push({ name: "changed" });
99
- bus.sink.changed();
100
- },
101
- },
402
+ events: sink,
102
403
  conversations,
404
+ surfaces,
405
+ turns,
103
406
  database: () => {
104
407
  if (!options.database) throw new PluginError("no database is configured");
105
408
  return options.database;
106
409
  },
107
- core,
108
- providers: { ...resolveProviders([plugin]), ...options.providers },
410
+ providers,
109
411
  dashboard: () => (linked ? registry.dashboard : unlinked("dashboard")),
110
412
  };
111
- const registry = await collectContributions([plugin], context, tiers);
413
+ const registry = await collectContributions(
414
+ [plugin],
415
+ context,
416
+ tiers,
417
+ services,
418
+ );
112
419
  linked = linkSessions(registry);
113
420
  bus.link(registry.handlers);
421
+ // With a team given, the agent server's claim and its target are in the router, as on a host.
422
+ const team = given.get(AGENTS.id)?.given;
423
+ const agentServer = team && "team" in team ? services.get(AGENTS) : undefined;
424
+ const attachmentDir = mkdtempSync(join(tmpdir(), "roundtable-test-plugin-"));
425
+ router = new ChannelRouter({
426
+ claims: [
427
+ ...(agentServer
428
+ ? [
429
+ agentClaim({
430
+ owner,
431
+ speakers: speakerPolicy({ owners: [owner.id] }),
432
+ // SAFETY: the claim reads the concrete team's members (guildId, owns, answerOwner, answerGroup, answerBackground, startFresh), which a test that gives a team for it supplies.
433
+ team: agentServer.team as unknown as Parameters<
434
+ typeof agentClaim
435
+ >[0]["team"],
436
+ runtime: {
437
+ steer: (...args) => services.get(AGENTS).runtime.steer(...args),
438
+ stop: (channel) => services.get(AGENTS).runtime.stop(channel),
439
+ },
440
+ surface: surfaces,
441
+ attachmentDir: () => attachmentDir,
442
+ logger,
443
+ }),
444
+ ]
445
+ : []),
446
+ ...registry.channels,
447
+ ],
448
+ targets: (name) =>
449
+ registry.backgroundTargets.find((t) => t.name === name) ??
450
+ (agentServer && name === OWNER_TARGET.name ? OWNER_TARGET : undefined),
451
+ queue,
452
+ logger,
453
+ ...(options.forwardJoinMs === undefined
454
+ ? {}
455
+ : { forwardJoinMs: options.forwardJoinMs }),
456
+ });
114
457
  const contribution: Contribution = {
115
458
  services: registry.services,
116
459
  events: registry.handlers[0]?.events,
117
- interactions: registry.interactions,
118
460
  http: registry.routes,
119
461
  holdRules: registry.holdRules,
120
462
  piPackages: registry.piPackages,
121
463
  sessionTools: registry.sessionTools,
122
464
  channels: registry.channels,
465
+ surfaces: registry.surfaces,
466
+ personas: registry.personas,
467
+ backgroundTargets: registry.backgroundTargets,
123
468
  dashboard: registry.dashboard,
124
469
  tools: registry.tools,
125
470
  seeds: registry.seeds,
126
471
  prompt: registry.prompt,
472
+ requiredTools: registry.requiredTools,
127
473
  ...(registry.agentSelections.length > 0
128
474
  ? { agentSelection: linked.agentSelection }
129
475
  : {}),
130
476
  };
477
+ // The plugin's own surfaces start as services; the injected ones start beside them, after.
478
+ const deliver = (message: InboundMessage) =>
479
+ void conversations.handle(message);
131
480
  for (const service of registry.services) await service.start?.();
481
+ for (const surface of options.surfaces ?? []) await surface.start(deliver);
482
+ await runtime?.preflight?.();
132
483
  let stopped = false;
133
484
  return {
134
485
  contribution,
486
+ holds: linked.holds,
487
+ conversations,
488
+ turns,
489
+ surfaces,
490
+ runtime,
135
491
  tools: registry.tools.map((tool) => tool.name),
136
492
  tiers,
137
493
  events,
@@ -148,10 +504,11 @@ export async function testPlugin(
148
504
  isError?: boolean;
149
505
  }>;
150
506
  }[] = [];
507
+ const channel = runOptions?.channel ?? "test:1";
151
508
  const session = {
152
509
  kind: "agent",
153
- homeChannel: "test:1",
154
- turnChannel: "test:1",
510
+ homeChannel: channel,
511
+ turnChannel: channel,
155
512
  compaction: { wrap: (factory) => factory },
156
513
  speaker: () => runOptions?.speaker,
157
514
  runTask: async () => {
@@ -175,8 +532,11 @@ export async function testPlugin(
175
532
  if (stopped) return;
176
533
  stopped = true;
177
534
  await bus.deliver("shutdown", []);
535
+ for (const surface of (options.surfaces ?? []).toReversed())
536
+ await surface.stop?.();
178
537
  for (const service of registry.services.toReversed())
179
538
  await service.stop?.();
539
+ await runtime?.dispose?.();
180
540
  },
181
541
  };
182
542
  }
@@ -1,52 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { readdirSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
-
5
- const root = join(import.meta.dir, "..");
6
- const guide = readFileSync(join(root, "docs/plugins.md"), "utf8");
7
- const files = readdirSync(import.meta.dir).filter((name) =>
8
- name.endsWith(".ts"),
9
- );
10
- const examples = files.filter(
11
- (name) => !name.endsWith(".test.ts") && name !== "guide.test.ts",
12
- );
13
-
14
- /** The guide embeds a file as a `ts` block between these two markers. */
15
- const MARKER = /<!-- example: (\S+) -->/g;
16
- const BLOCK =
17
- /<!-- example: (\S+) -->\n```ts\n([\s\S]*?)\n```\n<!-- \/example -->/g;
18
-
19
- test("every marker in the guide wraps one ts block", () => {
20
- expect([...guide.matchAll(BLOCK)].length).toBe(
21
- [...guide.matchAll(MARKER)].length,
22
- );
23
- expect([...guide.matchAll(MARKER)].length).toBeGreaterThan(0);
24
- });
25
-
26
- test("every block in the guide is exactly the file it names", () => {
27
- for (const [, path, embedded] of guide.matchAll(BLOCK)) {
28
- const file = readFileSync(join(root, path as string), "utf8");
29
- expect({ path, code: embedded }).toEqual({ path, code: file.trimEnd() });
30
- }
31
- });
32
-
33
- test("every example is embedded in the guide and has a test beside it", () => {
34
- const embedded = [...guide.matchAll(MARKER)].map((match) => match[1]);
35
- for (const name of examples) {
36
- expect(embedded).toContain(`examples/${name}`);
37
- expect(files).toContain(name.replace(/\.ts$/, ".test.ts"));
38
- }
39
- });
40
-
41
- test("examples import only the public entries, typebox, bun, and their neighbors", () => {
42
- for (const name of files.filter((file) => file !== "guide.test.ts")) {
43
- const source = readFileSync(join(import.meta.dir, name), "utf8");
44
- for (const [, specifier] of source.matchAll(/from "([^"]+)"/g))
45
- expect({ name, specifier }).toEqual({
46
- name,
47
- specifier: expect.stringMatching(
48
- /^(pi-roundtable|pi-roundtable\/testing|typebox|bun|bun:test|\.\/[a-z-]+\.ts)$/,
49
- ),
50
- });
51
- }
52
- });