pi-roundtable 0.7.19 → 0.9.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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -0,0 +1,460 @@
1
+ import { describe, test } from "bun:test";
2
+ import type { InboundMessage } from "../contract/channels.ts";
3
+ import type { ChatSurface } from "../contract/surface.ts";
4
+ import { parseChannelKey } from "../contract/surface.ts";
5
+ import type { TurnProgress } from "../domain/progress.ts";
6
+ import {
7
+ type Approval,
8
+ type PromptScope,
9
+ promptScope,
10
+ } from "../interactions/prompts.ts";
11
+ import type { ChannelKey } from "../sessions.ts";
12
+ import type { Speaker } from "../speakers.ts";
13
+
14
+ /** What the person on a surface's network has seen, as the contract reads it. */
15
+ export type SurfaceObservation =
16
+ | { kind: "reply"; text: string; files: readonly string[] }
17
+ | { kind: "typing"; on: boolean }
18
+ | { kind: "stop"; on: boolean }
19
+ | { kind: "progress"; event: TurnProgress }
20
+ | { kind: "prompt"; id: string; title: string }
21
+ | { kind: "prompt_closed"; id: string };
22
+
23
+ /**
24
+ * A surface under the contract, and one person on its network the contract acts as. The
25
+ * contract starts the surface, then calls `join`; it stops the surface after `close`.
26
+ */
27
+ export interface SurfaceContractSubject {
28
+ surface: ChatSurface;
29
+ /** A channel of the surface the person takes part in. */
30
+ channel: ChannelKey;
31
+ /** The person joins `channel`, once the surface has started. */
32
+ join?(): Promise<void>;
33
+ /** The person writes `text` in `channel`; the surface delivers it to the host. */
34
+ write(text: string): Promise<void>;
35
+ /** Everything the person has seen so far, oldest first. */
36
+ observations(): readonly SurfaceObservation[];
37
+ /** The person answers an open approval; without it the prompt checks are skipped. */
38
+ answer?(prompt: string, approved: boolean): Promise<void>;
39
+ /**
40
+ * The person as the speaker of the turns that ask them, given to `surface.prompts` as the
41
+ * scope `promptScope(speaker, visibility)`; default an owner-tier speaker. Every approval the
42
+ * contract asks for needs exactly the speaker's tier, so it is theirs to answer; one above a
43
+ * speaker below the owner tier checks that a private conversation escalates to no one.
44
+ */
45
+ speaker?: Speaker;
46
+ /**
47
+ * Someone else on the surface's network, another principal and not an owner, who knows the
48
+ * id of the person's open prompt and answers it; without it the responder-scope check is
49
+ * skipped.
50
+ */
51
+ stranger?: SurfaceContractAnswerer;
52
+ /**
53
+ * An owner on the surface's network, another principal than the person, who knows the id of
54
+ * the person's open prompt in their private conversation and answers it; without it that
55
+ * check is skipped.
56
+ */
57
+ owner?: SurfaceContractAnswerer;
58
+ close?(): Promise<void>;
59
+ }
60
+
61
+ /** Someone on the network other than the person, who answers a prompt by its id. */
62
+ export interface SurfaceContractAnswerer {
63
+ /** They join the surface, once it has started. */
64
+ join?(): Promise<void>;
65
+ answer(prompt: string, approved: boolean): Promise<void>;
66
+ }
67
+
68
+ /** One way the subject broke the contract. */
69
+ export interface SurfaceContractFailure {
70
+ name: string;
71
+ error: unknown;
72
+ }
73
+
74
+ interface Running {
75
+ subject: SurfaceContractSubject;
76
+ delivered: InboundMessage[];
77
+ }
78
+
79
+ interface Check {
80
+ name: string;
81
+ /** Whether the check applies to this subject; a check that does not is skipped. */
82
+ applies?(subject: SurfaceContractSubject): boolean;
83
+ run(running: Running): Promise<void>;
84
+ }
85
+
86
+ class ContractBroken extends Error {
87
+ override name = "ContractBroken";
88
+ }
89
+
90
+ function expectThat(condition: boolean, message: string): void {
91
+ if (!condition) throw new ContractBroken(message);
92
+ }
93
+
94
+ async function until(
95
+ done: () => boolean,
96
+ what: string,
97
+ ms = 2000,
98
+ ): Promise<void> {
99
+ const deadline = Date.now() + ms;
100
+ while (!done()) {
101
+ if (Date.now() > deadline)
102
+ throw new ContractBroken(`timed out waiting until ${what}`);
103
+ await Bun.sleep(5);
104
+ }
105
+ }
106
+
107
+ const seen = (running: Running, kind: SurfaceObservation["kind"]) =>
108
+ running.subject.observations().filter((o) => o.kind === kind);
109
+
110
+ const speakerOf = (subject: SurfaceContractSubject): Speaker =>
111
+ subject.speaker ?? {
112
+ id: "contract-speaker",
113
+ name: "Ada",
114
+ tier: "owner",
115
+ principalId: "contract-speaker",
116
+ };
117
+
118
+ /** The person's prompt scope in a conversation of this visibility. */
119
+ const scopeOf = (
120
+ subject: SurfaceContractSubject,
121
+ visibility: "private" | "shared" = "shared",
122
+ ): PromptScope => promptScope(speakerOf(subject), visibility);
123
+
124
+ /** The newest prompt the person has seen, once one more than `before` is open. */
125
+ async function nextPrompt(running: Running, before: number): Promise<string> {
126
+ await until(
127
+ () => seen(running, "prompt").length > before,
128
+ "the person sees the approval",
129
+ );
130
+ const prompt = seen(running, "prompt").at(-1);
131
+ if (prompt?.kind !== "prompt") throw new ContractBroken("no prompt seen");
132
+ return prompt.id;
133
+ }
134
+
135
+ /** Asks for one approval and answers it as the person does. */
136
+ async function approval(running: Running, approved: boolean) {
137
+ const { surface, channel, answer } = running.subject;
138
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
139
+ if (!prompts || !answer)
140
+ throw new ContractBroken("the surface shows no prompts");
141
+ const before = seen(running, "prompt").length;
142
+ const result = prompts.confirm(
143
+ "Approve?",
144
+ "Send the report.",
145
+ undefined,
146
+ speakerOf(running.subject).tier,
147
+ );
148
+ await answer(await nextPrompt(running, before), approved);
149
+ return result;
150
+ }
151
+
152
+ const CHECKS: readonly Check[] = [
153
+ {
154
+ name: "names a surface prefix without a colon, and owns the subject's channel",
155
+ run: async ({ subject }) => {
156
+ const { surface } = subject.surface;
157
+ expectThat(
158
+ surface !== "" && !surface.includes(":"),
159
+ `"${surface}" is not a surface name`,
160
+ );
161
+ expectThat(
162
+ parseChannelKey(subject.channel).surface === surface,
163
+ `${subject.channel} is not a channel of ${surface}`,
164
+ );
165
+ },
166
+ },
167
+ {
168
+ name: "delivers what the person writes, in their channel",
169
+ run: async (running) => {
170
+ await running.subject.write("hello from the contract");
171
+ await until(
172
+ () => running.delivered.length > 0,
173
+ "the surface delivers the message",
174
+ );
175
+ const [message] = running.delivered;
176
+ expectThat(
177
+ message?.channel === running.subject.channel,
178
+ `delivered to ${message?.channel}, not ${running.subject.channel}`,
179
+ );
180
+ expectThat(
181
+ message?.text === "hello from the contract",
182
+ `delivered the text ${JSON.stringify(message?.text)}`,
183
+ );
184
+ expectThat(
185
+ Boolean(message?.messageId) && Boolean(message?.authorId),
186
+ "a delivered message names its id and its author",
187
+ );
188
+ expectThat(message?.authorIsBot === false, "the person is not a bot");
189
+ },
190
+ },
191
+ {
192
+ name: "shows the person every chunk of a reply",
193
+ run: async (running) => {
194
+ const { surface, channel } = running.subject;
195
+ await surface.sendReply(channel, { chunks: ["first part", "second"] });
196
+ await until(
197
+ () => seen(running, "reply").length > 0,
198
+ "the person sees the reply",
199
+ );
200
+ const reply = seen(running, "reply")[0];
201
+ expectThat(
202
+ reply?.kind === "reply" &&
203
+ reply.text.includes("first part") &&
204
+ reply.text.includes("second"),
205
+ "the reply shows both chunks",
206
+ );
207
+ },
208
+ },
209
+ {
210
+ name: "delivers a reply's files when it supports them",
211
+ applies: (subject) => subject.surface.supportsFiles === true,
212
+ run: async (running) => {
213
+ const { surface, channel } = running.subject;
214
+ await surface.sendReply(channel, {
215
+ chunks: ["see the file"],
216
+ files: [{ name: "notes.txt", data: new TextEncoder().encode("hi") }],
217
+ });
218
+ await until(
219
+ () =>
220
+ seen(running, "reply").some(
221
+ (o) => o.kind === "reply" && o.files.includes("notes.txt"),
222
+ ),
223
+ "the person receives the file",
224
+ );
225
+ },
226
+ },
227
+ {
228
+ name: "a typing indicator ends when its handle is called, and calling it again is harmless",
229
+ applies: (subject) => subject.surface.startTyping !== undefined,
230
+ run: async (running) => {
231
+ const { surface, channel } = running.subject;
232
+ const stop = surface.startTyping?.(channel);
233
+ stop?.();
234
+ stop?.();
235
+ await until(
236
+ () => seen(running, "typing").some((o) => o.kind === "typing" && !o.on),
237
+ "the person sees typing end",
238
+ );
239
+ },
240
+ },
241
+ {
242
+ name: "a stop control ends when its handle is called, and calling it again is harmless",
243
+ applies: (subject) => subject.surface.showStop !== undefined,
244
+ run: async (running) => {
245
+ const { surface, channel } = running.subject;
246
+ const hide = surface.showStop?.(channel);
247
+ hide?.();
248
+ hide?.();
249
+ await until(
250
+ () => seen(running, "stop").some((o) => o.kind === "stop" && !o.on),
251
+ "the person sees the stop control go",
252
+ );
253
+ },
254
+ },
255
+ {
256
+ name: "shows a running turn's progress",
257
+ applies: (subject) => subject.surface.progress !== undefined,
258
+ run: async (running) => {
259
+ const { surface, channel } = running.subject;
260
+ await surface.progress?.(channel, { type: "text", delta: "Look" });
261
+ await surface.progress?.(channel, {
262
+ type: "tool_start",
263
+ id: "t1",
264
+ tool: "search",
265
+ });
266
+ await until(
267
+ () => seen(running, "progress").length >= 2,
268
+ "the person sees the progress",
269
+ );
270
+ },
271
+ },
272
+ {
273
+ name: "an approval resolves as the person answers it, and closes",
274
+ applies: (subject) =>
275
+ subject.surface.prompts !== undefined && subject.answer !== undefined,
276
+ run: async (running) => {
277
+ const approved = await approval(running, true);
278
+ expectThat(approved === "approved", `approving answered ${approved}`);
279
+ const declined = await approval(running, false);
280
+ expectThat(declined === "declined", `declining answered ${declined}`);
281
+ await until(
282
+ () => seen(running, "prompt_closed").length >= 2,
283
+ "both approvals close",
284
+ );
285
+ },
286
+ },
287
+ {
288
+ name: "a stopped turn's approval resolves cancelled and closes",
289
+ applies: (subject) =>
290
+ subject.surface.prompts !== undefined && subject.answer !== undefined,
291
+ run: async (running) => {
292
+ const { surface, channel } = running.subject;
293
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
294
+ if (!prompts) throw new ContractBroken("the surface shows no prompts");
295
+ const stop = new AbortController();
296
+ const before = seen(running, "prompt").length;
297
+ const result = prompts.confirm(
298
+ "Approve?",
299
+ "Send it.",
300
+ stop.signal,
301
+ speakerOf(running.subject).tier,
302
+ );
303
+ const id = await nextPrompt(running, before);
304
+ stop.abort();
305
+ const answer: Approval = await result;
306
+ expectThat(answer === "cancelled", `a stopped turn answered ${answer}`);
307
+ await until(
308
+ () =>
309
+ seen(running, "prompt_closed").some(
310
+ (o) => o.kind === "prompt_closed" && o.id === id,
311
+ ),
312
+ "the stopped approval closes",
313
+ );
314
+ },
315
+ },
316
+ {
317
+ name: "another person cannot answer the person's approval",
318
+ applies: (subject) =>
319
+ subject.surface.prompts !== undefined &&
320
+ subject.answer !== undefined &&
321
+ subject.stranger !== undefined,
322
+ run: (running) =>
323
+ answeredOnlyByThem(running, running.subject.stranger, "shared"),
324
+ },
325
+ {
326
+ name: "another principal cannot answer for the person in their private conversation, even an owner",
327
+ applies: (subject) =>
328
+ subject.surface.prompts !== undefined &&
329
+ subject.answer !== undefined &&
330
+ subject.owner !== undefined,
331
+ run: (running) =>
332
+ answeredOnlyByThem(running, running.subject.owner, "private"),
333
+ },
334
+ {
335
+ name: "a private conversation's approval above the person's tier goes to no one: it expires at once",
336
+ applies: (subject) =>
337
+ subject.surface.prompts !== undefined &&
338
+ speakerOf(subject).tier !== "owner",
339
+ run: async (running) => {
340
+ const { surface, channel } = running.subject;
341
+ const prompts = surface.prompts?.(
342
+ channel,
343
+ scopeOf(running.subject, "private"),
344
+ );
345
+ if (!prompts) throw new ContractBroken("the surface shows no prompts");
346
+ const before = seen(running, "prompt").length;
347
+ const answer = await Promise.race([
348
+ prompts.confirm("Approve?", "Delete the notes.", undefined, "owner"),
349
+ Bun.sleep(1000).then(() => "still open" as const),
350
+ ]);
351
+ expectThat(
352
+ answer === "expired",
353
+ `an approval no one may answer came back ${answer}`,
354
+ );
355
+ expectThat(
356
+ seen(running, "prompt").length === before,
357
+ "an approval no one may answer was shown",
358
+ );
359
+ },
360
+ },
361
+ ];
362
+
363
+ /**
364
+ * Opens the person's approval in a conversation of `visibility`, has `other` answer it by its id,
365
+ * and checks that only the person's own answer settles it.
366
+ */
367
+ async function answeredOnlyByThem(
368
+ running: Running,
369
+ other: SurfaceContractAnswerer | undefined,
370
+ visibility: "private" | "shared",
371
+ ): Promise<void> {
372
+ const { surface, channel, answer } = running.subject;
373
+ const prompts = surface.prompts?.(
374
+ channel,
375
+ scopeOf(running.subject, visibility),
376
+ );
377
+ if (!prompts || !answer || !other)
378
+ throw new ContractBroken("the surface shows no prompts");
379
+ await other.join?.();
380
+ const before = seen(running, "prompt").length;
381
+ let settled: Approval | undefined;
382
+ const result = prompts
383
+ .confirm(
384
+ "Approve?",
385
+ "Send the report.",
386
+ undefined,
387
+ speakerOf(running.subject).tier,
388
+ )
389
+ .then((value) => {
390
+ settled = value;
391
+ return value;
392
+ });
393
+ const id = await nextPrompt(running, before);
394
+ await other.answer(id, true);
395
+ await Bun.sleep(50);
396
+ expectThat(
397
+ settled === undefined,
398
+ `another principal's answer settled the approval as ${settled}`,
399
+ );
400
+ await answer(id, false);
401
+ const own = await result;
402
+ expectThat(own === "declined", `the person's answer came back ${own}`);
403
+ }
404
+
405
+ async function runCheck(
406
+ check: Check,
407
+ make: () => Promise<SurfaceContractSubject>,
408
+ ): Promise<"passed" | "skipped"> {
409
+ const subject = await make();
410
+ const running: Running = { subject, delivered: [] };
411
+ try {
412
+ if (check.applies && !check.applies(subject)) return "skipped";
413
+ await subject.surface.start((message) => running.delivered.push(message));
414
+ await subject.join?.();
415
+ await check.run(running);
416
+ return "passed";
417
+ } finally {
418
+ await subject.close?.();
419
+ await subject.surface.stop?.();
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Runs the chat surface contract on fresh subjects from `make`, one per check, and returns the
425
+ * checks the surface broke. `describeSurfaceContract` registers the same checks as tests.
426
+ */
427
+ export async function checkSurfaceContract(
428
+ make: () => Promise<SurfaceContractSubject>,
429
+ ): Promise<SurfaceContractFailure[]> {
430
+ const failures: SurfaceContractFailure[] = [];
431
+ for (const check of CHECKS) {
432
+ try {
433
+ await runCheck(check, make);
434
+ } catch (error) {
435
+ failures.push({ name: check.name, error });
436
+ }
437
+ }
438
+ return failures;
439
+ }
440
+
441
+ /**
442
+ * Registers the chat surface contract as one test per check, under `name`: the surface names a
443
+ * prefix and owns its channel, delivers what its person writes, shows every chunk of a reply, and,
444
+ * where it offers them, delivers files, ends typing and stop controls idempotently, shows
445
+ * progress, resolves approvals as the person answers or as a stopped turn cancels them, ignores
446
+ * another person's answer to the person's approval, and, in a private conversation, an owner's
447
+ * too, and lets an approval above the person's tier there expire at once, shown to no one. Each
448
+ * check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
449
+ */
450
+ export function describeSurfaceContract(
451
+ name: string,
452
+ make: () => Promise<SurfaceContractSubject>,
453
+ ): void {
454
+ describe(`${name} keeps the chat surface contract`, () => {
455
+ for (const check of CHECKS)
456
+ test(check.name, async () => {
457
+ await runCheck(check, make);
458
+ });
459
+ });
460
+ }
@@ -11,27 +11,32 @@ import { type RoundtableConfig, resolveConfig } from "../config/config.ts";
11
11
  import type { ConversationPort } from "../contract/channels.ts";
12
12
  import type { AgentRuntime } from "../contract/runtime.ts";
13
13
  import type { ChatSurface } from "../contract/surface.ts";
14
- import { defineRoundtable } from "../define-roundtable.ts";
14
+ import { defineRoundtable, discordOwnerOf } from "../define-roundtable.ts";
15
15
  import { CommandCollection } from "../discord/command-collection.ts";
16
16
  import type { ComposedCommands } from "../discord/compose-commands.ts";
17
17
  import type { DiscordConnection } from "../discord/connection.ts";
18
+ import { discordDirectChannel } from "../discord/direct-channel.ts";
18
19
  import type {
19
20
  CommandGuard,
20
21
  InteractionContribution,
21
22
  } from "../discord/interaction-module.ts";
22
- import { OwnerGuard, ownerRootCommand } from "../discord/owner-command.ts";
23
+ import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
23
24
  import { Roundtable } from "../host.ts";
24
25
  import { silentLogger } from "../log.ts";
25
26
  import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
26
27
  import { CompactionTiers } from "../runtime/compaction-tiers.ts";
28
+ import { privateCompaction } from "../runtime/extensions/private-memory.ts";
27
29
  import { sessionExtensions } from "../runtime/runtime-types.ts";
30
+ import { IDENTITY } from "../services.ts";
28
31
  import type { AgentTurnScope, SessionContext } from "../sessions.ts";
32
+ import { THE_SPEAKER } from "../speakers.ts";
29
33
  import { testDatabaseUrl } from "./database.ts";
30
34
 
31
35
  export interface TestHostOptions {
32
36
  /**
33
- * Over a test configuration: an owner, a guild, a temporary `dataDir`, and the test database
34
- * (`ROUNDTABLE_TEST_DATABASE_URL`).
37
+ * Over a test configuration: the owner Ada in `access`, a guild, a temporary `dataDir`, and
38
+ * the test database (`ROUNDTABLE_TEST_DATABASE_URL`). Giving the 0.8 `owner` or `speakers`
39
+ * replaces the default `access`.
35
40
  */
36
41
  config?: Partial<RoundtableConfig>;
37
42
  /** The app's plugins, after the built-in ones, as `defineRoundtable` places them. */
@@ -43,8 +48,13 @@ export interface TestHostOptions {
43
48
  * having none, whatever login the machine holds.
44
49
  */
45
50
  apiKeys?: Readonly<Record<string, string>>;
46
- /** What the stand-in Discord hands out. */
47
- discord?: Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
51
+ /**
52
+ * What the stand-in Discord hands out. `false` boots a host without Discord: no `discord`,
53
+ * `http`, or `agents` in the configuration, so no Discord plugin and no agent server.
54
+ */
55
+ discord?:
56
+ | false
57
+ | Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
48
58
  }
49
59
 
50
60
  export interface TestHost {
@@ -68,10 +78,28 @@ export interface TestHost {
68
78
  stop(): Promise<void>;
69
79
  }
70
80
 
71
- function defaultConfig(): RoundtableConfig {
81
+ /** The test host's owner, Ada, as `access` writes her: her principal is her Discord user id. */
82
+ const TEST_ACCESS: NonNullable<RoundtableConfig["access"]> = {
83
+ owners: [
84
+ {
85
+ name: "Ada",
86
+ principal: "100000000000000001",
87
+ identities: ["discord:100000000000000001"],
88
+ },
89
+ ],
90
+ };
91
+
92
+ function defaultConfig(withDiscord: boolean): RoundtableConfig {
72
93
  const dataDir = mkdtempSync(join(tmpdir(), "roundtable-test-host-"));
94
+ if (!withDiscord)
95
+ return {
96
+ access: TEST_ACCESS,
97
+ database: { url: testDatabaseUrl },
98
+ dataDir,
99
+ model: "anthropic/claude-sonnet-5-5",
100
+ };
73
101
  return {
74
- owner: { id: "100000000000000001", name: "Ada" },
102
+ access: TEST_ACCESS,
75
103
  discord: {
76
104
  token: "token",
77
105
  guild: "900000000000000001",
@@ -110,10 +138,14 @@ function quietRuntime(): AgentRuntime {
110
138
  }
111
139
 
112
140
  /** A Discord that connects to nothing, recording the commands added to it, standing in for the built-in plugin that does. */
113
- function standInDiscord(
141
+ export function standInDiscord(
114
142
  commands: CommandCollection,
115
143
  guard: CommandGuard,
116
144
  given: TestHostOptions["discord"],
145
+ /** The primary owner's Discord user id, whose direct messages are the owner channel. */
146
+ ownerId: string,
147
+ /** What `DISCORD.channelContext` answers; by default no context, as on a host with it off. */
148
+ channelContext: DiscordServices["channelContext"] = async () => undefined,
117
149
  ): RoundtablePlugin {
118
150
  const surface: ChatSurface = {
119
151
  surface: "discord",
@@ -125,6 +157,18 @@ function standInDiscord(
125
157
  provides: [DISCORD],
126
158
  replaces: [DISCORD],
127
159
  setup: ({ services }) => {
160
+ const identity = services.find(IDENTITY);
161
+ const ownerChannel =
162
+ (given ? given.ownerChannel : undefined) ??
163
+ (async () => "discord:owner-dm" as const);
164
+ // Each person's direct messages, as the Discord plugin's provider reaches them; the owner's is the owner channel.
165
+ const direct = discordDirectChannel({
166
+ directChannel: async (userId) =>
167
+ userId === ownerId ? ownerChannel() : `discord:dm-${userId}`,
168
+ sendDirect: async () => undefined,
169
+ ...(identity ? { identity } : {}),
170
+ ownerId,
171
+ });
128
172
  // SAFETY: the plugins after it read only these members while they set up; the rest is read when a tool runs, which a test host does not do.
129
173
  services.provide(DISCORD, {
130
174
  connection: {
@@ -138,8 +182,9 @@ function standInDiscord(
138
182
  commands: commands.registrar,
139
183
  guard,
140
184
  threads: { open: async () => undefined, sweep: async () => undefined },
185
+ channelContext,
141
186
  } as unknown as DiscordServices);
142
- return { surfaces: [surface] };
187
+ return { surfaces: [surface], directChannels: [direct] };
143
188
  },
144
189
  };
145
190
  }
@@ -156,8 +201,8 @@ function ignored(): Ignored {
156
201
  /** The tools a factory registers, by name; every other registration (handlers, commands, events) is accepted and ignored. */
157
202
  async function registeredBy(factory: ExtensionFactory): Promise<string[]> {
158
203
  const names: string[] = [];
159
- // SAFETY: a session factory only registers tools, handlers and commands; the fake records the tool names and ignores the rest.
160
204
  const registerTool = (tool: { name: string }) => names.push(tool.name);
205
+ // SAFETY: a session factory only registers tools, handlers and commands; the fake records the tool names and ignores the rest.
161
206
  const api = new Proxy(
162
207
  {},
163
208
  {
@@ -176,6 +221,7 @@ const CORE_EXTENSIONS = {
176
221
  confirmationGate: noop,
177
222
  askUser: noop,
178
223
  selfCompactGuard: noop,
224
+ privateMemory: noop,
179
225
  activeTools: noop,
180
226
  };
181
227
 
@@ -197,14 +243,33 @@ export async function testHost(
197
243
  return { services: [{ name: "probe" }] };
198
244
  },
199
245
  };
200
- const base: RoundtableConfig = { ...defaultConfig(), ...options.config };
246
+ const withDiscord = options.discord !== false;
247
+ const given = options.discord === false ? undefined : options.discord;
248
+ const defaults = defaultConfig(withDiscord);
249
+ // A test that writes the 0.8 owner or speakers gets them in place of the default access.
250
+ if (options.config?.owner || options.config?.speakers) delete defaults.access;
251
+ const base: RoundtableConfig = { ...defaults, ...options.config };
201
252
  const resolved = resolveConfig(base);
202
- const { rootCommand } = resolved.discord;
203
- const guard = new OwnerGuard(resolved.owner.id, silentLogger(), rootCommand);
253
+ const rootCommand = resolved.slug;
254
+ // Like the Discord plugin's, the guard asks the host's identity service, once the host is up.
255
+ const identity = () => captured?.services.find(IDENTITY);
256
+ const guard = commandGuard({
257
+ ownerId: discordOwnerOf(resolved).id,
258
+ identity: {
259
+ resolve: async (facts, scope) => identity()?.resolve(facts, scope),
260
+ owners: async () => (await identity()?.owners()) ?? [],
261
+ identities: async (principalId) =>
262
+ (await identity()?.identities(principalId)) ?? [],
263
+ },
264
+ root: rootCommand,
265
+ logger: silentLogger(),
266
+ });
204
267
  const config: RoundtableConfig = {
205
268
  ...base,
206
269
  plugins: [
207
- standInDiscord(commands, guard, options.discord),
270
+ ...(withDiscord
271
+ ? [standInDiscord(commands, guard, given, discordOwnerOf(resolved).id)]
272
+ : []),
208
273
  {
209
274
  name: "test-runtime",
210
275
  providers: { runtime: () => runtime },
@@ -227,9 +292,10 @@ export async function testHost(
227
292
  await roundtable.run();
228
293
  if (!captured) throw new Error("the probe was not set up");
229
294
  const context = captured;
230
- const ownerChannel = await (
231
- options.discord?.ownerChannel ?? (async () => "discord:owner-dm" as const)
232
- )();
295
+ // Without Discord the owner's session is a conversation of a plugin's surface.
296
+ const ownerChannel = withDiscord
297
+ ? await (given?.ownerChannel ?? (async () => "discord:owner-dm" as const))()
298
+ : ("test:owner" as const);
233
299
  /** A group seat's turns run in the group's channel; an agent's in its own. */
234
300
  const turnChannelOf = (scope?: AgentTurnScope) =>
235
301
  scope?.group ? scope.session : scope?.home;
@@ -243,9 +309,24 @@ export async function testHost(
243
309
  kind: scope ? "agent" : "owner",
244
310
  homeChannel: scope?.home ?? ownerChannel,
245
311
  turnChannel: turnChannelOf(scope) ?? ownerChannel,
312
+ // Shared, as the runtime makes it, so its compactor summarizes no one's private memory.
246
313
  compaction: {
247
- wrap: (compactor) => tiers.wrapCompactor(compactor, () => undefined),
314
+ wrap: (compactor) =>
315
+ tiers.wrapCompactor(
316
+ compactor,
317
+ () => undefined,
318
+ (event) =>
319
+ privateCompaction(
320
+ event.preparation,
321
+ event.branchEntries.flatMap((entry) =>
322
+ entry.type === "message" ? [entry.message] : [],
323
+ ),
324
+ ),
325
+ ),
248
326
  },
327
+ conversation: { visibility: "shared" },
328
+ addressee: THE_SPEAKER,
329
+ memory: "speaker",
249
330
  speaker: () => undefined,
250
331
  runTask: async () => {
251
332
  throw new Error("a test host session cannot run tasks");