pi-roundtable 0.7.18 → 0.8.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 (74) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +44 -11
  3. package/README.zh-TW.md +44 -9
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/plugins.md +268 -23
  6. package/examples/discord-adapter.test.ts +42 -0
  7. package/examples/discord-adapter.ts +26 -0
  8. package/examples/echo-runtime.ts +1 -1
  9. package/examples/headless.test.ts +72 -0
  10. package/examples/headless.ts +31 -0
  11. package/examples/http.test.ts +31 -1
  12. package/examples/http.ts +39 -0
  13. package/package.json +3 -2
  14. package/src/cli/cli.ts +15 -4
  15. package/src/cli/doctor.ts +21 -5
  16. package/src/cli/init.ts +9 -1
  17. package/src/cli/project.ts +28 -2
  18. package/src/cli/templates.ts +46 -14
  19. package/src/core/agents/agent-team-fixture.ts +6 -0
  20. package/src/core/agents/team-turns.ts +6 -1
  21. package/src/core/builtin/agent-server.ts +30 -95
  22. package/src/core/builtin/modules.ts +133 -34
  23. package/src/core/builtin/stores.ts +29 -14
  24. package/src/core/config/adapters.ts +57 -0
  25. package/src/core/config/config.ts +174 -77
  26. package/src/core/contract/channels.ts +4 -0
  27. package/src/core/contract/providers.ts +3 -2
  28. package/src/core/contract/runtime.ts +8 -4
  29. package/src/core/contract/surface.ts +13 -0
  30. package/src/core/conversations/conversation-registry.ts +42 -0
  31. package/src/core/conversations/conversation-store.ts +113 -0
  32. package/src/core/conversations/conversations-plugin.ts +29 -0
  33. package/src/core/define-roundtable.ts +124 -54
  34. package/src/core/discord/discord-adapter.ts +9 -0
  35. package/src/core/discord/owner-cards.ts +19 -8
  36. package/src/core/domain/conversation.ts +5 -0
  37. package/src/core/domain/owner-prompts.ts +1 -1
  38. package/src/core/domain/ports.ts +6 -0
  39. package/src/core/domain/progress.ts +10 -0
  40. package/src/core/events.ts +1 -0
  41. package/src/core/host.ts +6 -3
  42. package/src/core/http/listeners.ts +174 -21
  43. package/src/core/http/websocket.ts +316 -0
  44. package/src/core/i18n/cards.ts +5 -7
  45. package/src/core/modules/memory/owner-memory.ts +5 -3
  46. package/src/core/ops/error-reporter.ts +24 -12
  47. package/src/core/plugin.ts +14 -0
  48. package/src/core/registry/providers.ts +1 -1
  49. package/src/core/routing/channel-router.ts +8 -0
  50. package/src/core/routing/conversation-turns.ts +55 -1
  51. package/src/core/routing/surface-port.ts +3 -0
  52. package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
  53. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  54. package/src/core/runtime/pending-confirmation-store.ts +42 -4
  55. package/src/core/runtime/pi-agent-runtime.ts +13 -6
  56. package/src/core/runtime/prompt-slot.ts +2 -2
  57. package/src/core/runtime/runtime-plugin.ts +160 -0
  58. package/src/core/runtime/turn-progress.ts +116 -0
  59. package/src/core/services.ts +15 -1
  60. package/src/core/shared/unix-server.ts +35 -5
  61. package/src/core/testing/modules.ts +47 -23
  62. package/src/core/testing/surface-contract.ts +383 -0
  63. package/src/core/testing/test-host.ts +27 -9
  64. package/src/discord/index.ts +2 -0
  65. package/src/index.ts +18 -0
  66. package/src/testing.ts +41 -5
  67. package/templates/adapters/web/.env.example +24 -0
  68. package/templates/adapters/web/README.md +15 -0
  69. package/templates/adapters/web/package.json.tmpl +26 -0
  70. package/templates/adapters/web/persona/assistant.md +2 -0
  71. package/templates/adapters/web/roundtable.config.ts +55 -0
  72. package/templates/package.json.tmpl +1 -0
  73. package/templates/plugins/self-compact.ts +11 -0
  74. package/templates/roundtable.config.ts +2 -1
@@ -0,0 +1,383 @@
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 { Approval } from "../domain/owner-prompts.ts";
6
+ import type { TurnProgress } from "../domain/progress.ts";
7
+ import type { ChannelKey } from "../sessions.ts";
8
+ import type { Speaker } from "../speakers.ts";
9
+
10
+ /** What the person on a surface's network has seen, as the contract reads it. */
11
+ export type SurfaceObservation =
12
+ | { kind: "reply"; text: string; files: readonly string[] }
13
+ | { kind: "typing"; on: boolean }
14
+ | { kind: "stop"; on: boolean }
15
+ | { kind: "progress"; event: TurnProgress }
16
+ | { kind: "prompt"; id: string; title: string }
17
+ | { kind: "prompt_closed"; id: string };
18
+
19
+ /**
20
+ * A surface under the contract, and one person on its network the contract acts as. The
21
+ * contract starts the surface, then calls `join`; it stops the surface after `close`.
22
+ */
23
+ export interface SurfaceContractSubject {
24
+ surface: ChatSurface;
25
+ /** A channel of the surface the person takes part in. */
26
+ channel: ChannelKey;
27
+ /** The person joins `channel`, once the surface has started. */
28
+ join?(): Promise<void>;
29
+ /** The person writes `text` in `channel`; the surface delivers it to the host. */
30
+ write(text: string): Promise<void>;
31
+ /** Everything the person has seen so far, oldest first. */
32
+ observations(): readonly SurfaceObservation[];
33
+ /** The person answers an open approval; without it the prompt checks are skipped. */
34
+ answer?(prompt: string, approved: boolean): Promise<void>;
35
+ /**
36
+ * The person as the speaker of the turns that ask them, given to `surface.prompts`; default an
37
+ * owner-tier speaker. Every approval the contract asks for needs exactly the speaker's tier, so
38
+ * it is theirs to answer.
39
+ */
40
+ speaker?: Speaker;
41
+ /**
42
+ * Someone else on the surface's network, not the owner, who knows the id of the person's open
43
+ * prompt and answers it; without it the responder-scope check is skipped.
44
+ */
45
+ stranger?: {
46
+ /** They join the surface, once it has started. */
47
+ join?(): Promise<void>;
48
+ answer(prompt: string, approved: boolean): Promise<void>;
49
+ };
50
+ close?(): Promise<void>;
51
+ }
52
+
53
+ /** One way the subject broke the contract. */
54
+ export interface SurfaceContractFailure {
55
+ name: string;
56
+ error: unknown;
57
+ }
58
+
59
+ interface Running {
60
+ subject: SurfaceContractSubject;
61
+ delivered: InboundMessage[];
62
+ }
63
+
64
+ interface Check {
65
+ name: string;
66
+ /** Whether the check applies to this subject; a check that does not is skipped. */
67
+ applies?(subject: SurfaceContractSubject): boolean;
68
+ run(running: Running): Promise<void>;
69
+ }
70
+
71
+ class ContractBroken extends Error {
72
+ override name = "ContractBroken";
73
+ }
74
+
75
+ function expectThat(condition: boolean, message: string): void {
76
+ if (!condition) throw new ContractBroken(message);
77
+ }
78
+
79
+ async function until(
80
+ done: () => boolean,
81
+ what: string,
82
+ ms = 2000,
83
+ ): Promise<void> {
84
+ const deadline = Date.now() + ms;
85
+ while (!done()) {
86
+ if (Date.now() > deadline)
87
+ throw new ContractBroken(`timed out waiting until ${what}`);
88
+ await Bun.sleep(5);
89
+ }
90
+ }
91
+
92
+ const seen = (running: Running, kind: SurfaceObservation["kind"]) =>
93
+ running.subject.observations().filter((o) => o.kind === kind);
94
+
95
+ const speakerOf = (subject: SurfaceContractSubject): Speaker =>
96
+ subject.speaker ?? { id: "contract-speaker", name: "Ada", tier: "owner" };
97
+
98
+ /** The newest prompt the person has seen, once one more than `before` is open. */
99
+ async function nextPrompt(running: Running, before: number): Promise<string> {
100
+ await until(
101
+ () => seen(running, "prompt").length > before,
102
+ "the person sees the approval",
103
+ );
104
+ const prompt = seen(running, "prompt").at(-1);
105
+ if (prompt?.kind !== "prompt") throw new ContractBroken("no prompt seen");
106
+ return prompt.id;
107
+ }
108
+
109
+ /** Asks for one approval and answers it as the person does. */
110
+ async function approval(running: Running, approved: boolean) {
111
+ const { surface, channel, answer } = running.subject;
112
+ const prompts = surface.prompts?.(channel, speakerOf(running.subject));
113
+ if (!prompts || !answer)
114
+ throw new ContractBroken("the surface shows no prompts");
115
+ const before = seen(running, "prompt").length;
116
+ const result = prompts.confirm(
117
+ "Approve?",
118
+ "Send the report.",
119
+ undefined,
120
+ speakerOf(running.subject).tier,
121
+ );
122
+ await answer(await nextPrompt(running, before), approved);
123
+ return result;
124
+ }
125
+
126
+ const CHECKS: readonly Check[] = [
127
+ {
128
+ name: "names a surface prefix without a colon, and owns the subject's channel",
129
+ run: async ({ subject }) => {
130
+ const { surface } = subject.surface;
131
+ expectThat(
132
+ surface !== "" && !surface.includes(":"),
133
+ `"${surface}" is not a surface name`,
134
+ );
135
+ expectThat(
136
+ parseChannelKey(subject.channel).surface === surface,
137
+ `${subject.channel} is not a channel of ${surface}`,
138
+ );
139
+ },
140
+ },
141
+ {
142
+ name: "delivers what the person writes, in their channel",
143
+ run: async (running) => {
144
+ await running.subject.write("hello from the contract");
145
+ await until(
146
+ () => running.delivered.length > 0,
147
+ "the surface delivers the message",
148
+ );
149
+ const [message] = running.delivered;
150
+ expectThat(
151
+ message?.channel === running.subject.channel,
152
+ `delivered to ${message?.channel}, not ${running.subject.channel}`,
153
+ );
154
+ expectThat(
155
+ message?.text === "hello from the contract",
156
+ `delivered the text ${JSON.stringify(message?.text)}`,
157
+ );
158
+ expectThat(
159
+ Boolean(message?.messageId) && Boolean(message?.authorId),
160
+ "a delivered message names its id and its author",
161
+ );
162
+ expectThat(message?.authorIsBot === false, "the person is not a bot");
163
+ },
164
+ },
165
+ {
166
+ name: "shows the person every chunk of a reply",
167
+ run: async (running) => {
168
+ const { surface, channel } = running.subject;
169
+ await surface.sendReply(channel, { chunks: ["first part", "second"] });
170
+ await until(
171
+ () => seen(running, "reply").length > 0,
172
+ "the person sees the reply",
173
+ );
174
+ const reply = seen(running, "reply")[0];
175
+ expectThat(
176
+ reply?.kind === "reply" &&
177
+ reply.text.includes("first part") &&
178
+ reply.text.includes("second"),
179
+ "the reply shows both chunks",
180
+ );
181
+ },
182
+ },
183
+ {
184
+ name: "delivers a reply's files when it supports them",
185
+ applies: (subject) => subject.surface.supportsFiles === true,
186
+ run: async (running) => {
187
+ const { surface, channel } = running.subject;
188
+ await surface.sendReply(channel, {
189
+ chunks: ["see the file"],
190
+ files: [{ name: "notes.txt", data: new TextEncoder().encode("hi") }],
191
+ });
192
+ await until(
193
+ () =>
194
+ seen(running, "reply").some(
195
+ (o) => o.kind === "reply" && o.files.includes("notes.txt"),
196
+ ),
197
+ "the person receives the file",
198
+ );
199
+ },
200
+ },
201
+ {
202
+ name: "a typing indicator ends when its handle is called, and calling it again is harmless",
203
+ applies: (subject) => subject.surface.startTyping !== undefined,
204
+ run: async (running) => {
205
+ const { surface, channel } = running.subject;
206
+ const stop = surface.startTyping?.(channel);
207
+ stop?.();
208
+ stop?.();
209
+ await until(
210
+ () => seen(running, "typing").some((o) => o.kind === "typing" && !o.on),
211
+ "the person sees typing end",
212
+ );
213
+ },
214
+ },
215
+ {
216
+ name: "a stop control ends when its handle is called, and calling it again is harmless",
217
+ applies: (subject) => subject.surface.showStop !== undefined,
218
+ run: async (running) => {
219
+ const { surface, channel } = running.subject;
220
+ const hide = surface.showStop?.(channel);
221
+ hide?.();
222
+ hide?.();
223
+ await until(
224
+ () => seen(running, "stop").some((o) => o.kind === "stop" && !o.on),
225
+ "the person sees the stop control go",
226
+ );
227
+ },
228
+ },
229
+ {
230
+ name: "shows a running turn's progress",
231
+ applies: (subject) => subject.surface.progress !== undefined,
232
+ run: async (running) => {
233
+ const { surface, channel } = running.subject;
234
+ await surface.progress?.(channel, { type: "text", delta: "Look" });
235
+ await surface.progress?.(channel, {
236
+ type: "tool_start",
237
+ id: "t1",
238
+ tool: "search",
239
+ });
240
+ await until(
241
+ () => seen(running, "progress").length >= 2,
242
+ "the person sees the progress",
243
+ );
244
+ },
245
+ },
246
+ {
247
+ name: "an approval resolves as the person answers it, and closes",
248
+ applies: (subject) =>
249
+ subject.surface.prompts !== undefined && subject.answer !== undefined,
250
+ run: async (running) => {
251
+ const approved = await approval(running, true);
252
+ expectThat(approved === "approved", `approving answered ${approved}`);
253
+ const declined = await approval(running, false);
254
+ expectThat(declined === "declined", `declining answered ${declined}`);
255
+ await until(
256
+ () => seen(running, "prompt_closed").length >= 2,
257
+ "both approvals close",
258
+ );
259
+ },
260
+ },
261
+ {
262
+ name: "a stopped turn's approval resolves cancelled and closes",
263
+ applies: (subject) =>
264
+ subject.surface.prompts !== undefined && subject.answer !== undefined,
265
+ run: async (running) => {
266
+ const { surface, channel } = running.subject;
267
+ const prompts = surface.prompts?.(channel, speakerOf(running.subject));
268
+ if (!prompts) throw new ContractBroken("the surface shows no prompts");
269
+ const stop = new AbortController();
270
+ const before = seen(running, "prompt").length;
271
+ const result = prompts.confirm(
272
+ "Approve?",
273
+ "Send it.",
274
+ stop.signal,
275
+ speakerOf(running.subject).tier,
276
+ );
277
+ const id = await nextPrompt(running, before);
278
+ stop.abort();
279
+ const answer: Approval = await result;
280
+ expectThat(answer === "cancelled", `a stopped turn answered ${answer}`);
281
+ await until(
282
+ () =>
283
+ seen(running, "prompt_closed").some(
284
+ (o) => o.kind === "prompt_closed" && o.id === id,
285
+ ),
286
+ "the stopped approval closes",
287
+ );
288
+ },
289
+ },
290
+ {
291
+ name: "another person cannot answer the person's approval",
292
+ applies: (subject) =>
293
+ subject.surface.prompts !== undefined &&
294
+ subject.answer !== undefined &&
295
+ subject.stranger !== undefined,
296
+ run: async (running) => {
297
+ const { surface, channel, answer, stranger } = running.subject;
298
+ const prompts = surface.prompts?.(channel, speakerOf(running.subject));
299
+ if (!prompts || !answer || !stranger)
300
+ throw new ContractBroken("the surface shows no prompts");
301
+ await stranger.join?.();
302
+ const before = seen(running, "prompt").length;
303
+ let settled: Approval | undefined;
304
+ const result = prompts
305
+ .confirm(
306
+ "Approve?",
307
+ "Send the report.",
308
+ undefined,
309
+ speakerOf(running.subject).tier,
310
+ )
311
+ .then((value) => {
312
+ settled = value;
313
+ return value;
314
+ });
315
+ const id = await nextPrompt(running, before);
316
+ await stranger.answer(id, true);
317
+ await Bun.sleep(50);
318
+ expectThat(
319
+ settled === undefined,
320
+ `another person's answer settled the approval as ${settled}`,
321
+ );
322
+ await answer(id, false);
323
+ const own = await result;
324
+ expectThat(own === "declined", `the person's answer came back ${own}`);
325
+ },
326
+ },
327
+ ];
328
+
329
+ async function runCheck(
330
+ check: Check,
331
+ make: () => Promise<SurfaceContractSubject>,
332
+ ): Promise<"passed" | "skipped"> {
333
+ const subject = await make();
334
+ const running: Running = { subject, delivered: [] };
335
+ try {
336
+ if (check.applies && !check.applies(subject)) return "skipped";
337
+ await subject.surface.start((message) => running.delivered.push(message));
338
+ await subject.join?.();
339
+ await check.run(running);
340
+ return "passed";
341
+ } finally {
342
+ await subject.close?.();
343
+ await subject.surface.stop?.();
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Runs the chat surface contract on fresh subjects from `make`, one per check, and returns the
349
+ * checks the surface broke. `describeSurfaceContract` registers the same checks as tests.
350
+ */
351
+ export async function checkSurfaceContract(
352
+ make: () => Promise<SurfaceContractSubject>,
353
+ ): Promise<SurfaceContractFailure[]> {
354
+ const failures: SurfaceContractFailure[] = [];
355
+ for (const check of CHECKS) {
356
+ try {
357
+ await runCheck(check, make);
358
+ } catch (error) {
359
+ failures.push({ name: check.name, error });
360
+ }
361
+ }
362
+ return failures;
363
+ }
364
+
365
+ /**
366
+ * Registers the chat surface contract as one test per check, under `name`: the surface names a
367
+ * prefix and owns its channel, delivers what its person writes, shows every chunk of a reply, and,
368
+ * where it offers them, delivers files, ends typing and stop controls idempotently, shows
369
+ * progress, resolves approvals as the person answers or as a stopped turn cancels them, and
370
+ * ignores another person's answer to the person's approval. Each
371
+ * check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
372
+ */
373
+ export function describeSurfaceContract(
374
+ name: string,
375
+ make: () => Promise<SurfaceContractSubject>,
376
+ ): void {
377
+ describe(`${name} keeps the chat surface contract`, () => {
378
+ for (const check of CHECKS)
379
+ test(check.name, async () => {
380
+ await runCheck(check, make);
381
+ });
382
+ });
383
+ }
@@ -43,8 +43,13 @@ export interface TestHostOptions {
43
43
  * having none, whatever login the machine holds.
44
44
  */
45
45
  apiKeys?: Readonly<Record<string, string>>;
46
- /** What the stand-in Discord hands out. */
47
- discord?: Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
46
+ /**
47
+ * What the stand-in Discord hands out. `false` boots a host without Discord: no `discord`,
48
+ * `http`, or `agents` in the configuration, so no Discord plugin and no agent server.
49
+ */
50
+ discord?:
51
+ | false
52
+ | Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
48
53
  }
49
54
 
50
55
  export interface TestHost {
@@ -68,8 +73,15 @@ export interface TestHost {
68
73
  stop(): Promise<void>;
69
74
  }
70
75
 
71
- function defaultConfig(): RoundtableConfig {
76
+ function defaultConfig(withDiscord: boolean): RoundtableConfig {
72
77
  const dataDir = mkdtempSync(join(tmpdir(), "roundtable-test-host-"));
78
+ if (!withDiscord)
79
+ return {
80
+ owner: { id: "100000000000000001", name: "Ada" },
81
+ database: { url: testDatabaseUrl },
82
+ dataDir,
83
+ model: "anthropic/claude-sonnet-5-5",
84
+ };
73
85
  return {
74
86
  owner: { id: "100000000000000001", name: "Ada" },
75
87
  discord: {
@@ -197,14 +209,19 @@ export async function testHost(
197
209
  return { services: [{ name: "probe" }] };
198
210
  },
199
211
  };
200
- const base: RoundtableConfig = { ...defaultConfig(), ...options.config };
212
+ const withDiscord = options.discord !== false;
213
+ const given = options.discord === false ? undefined : options.discord;
214
+ const base: RoundtableConfig = {
215
+ ...defaultConfig(withDiscord),
216
+ ...options.config,
217
+ };
201
218
  const resolved = resolveConfig(base);
202
- const { rootCommand } = resolved.discord;
219
+ const rootCommand = resolved.slug;
203
220
  const guard = new OwnerGuard(resolved.owner.id, silentLogger(), rootCommand);
204
221
  const config: RoundtableConfig = {
205
222
  ...base,
206
223
  plugins: [
207
- standInDiscord(commands, guard, options.discord),
224
+ ...(withDiscord ? [standInDiscord(commands, guard, given)] : []),
208
225
  {
209
226
  name: "test-runtime",
210
227
  providers: { runtime: () => runtime },
@@ -227,9 +244,10 @@ export async function testHost(
227
244
  await roundtable.run();
228
245
  if (!captured) throw new Error("the probe was not set up");
229
246
  const context = captured;
230
- const ownerChannel = await (
231
- options.discord?.ownerChannel ?? (async () => "discord:owner-dm" as const)
232
- )();
247
+ // Without Discord the owner's session is a conversation of a plugin's surface.
248
+ const ownerChannel = withDiscord
249
+ ? await (given?.ownerChannel ?? (async () => "discord:owner-dm" as const))()
250
+ : ("test:owner" as const);
233
251
  /** A group seat's turns run in the group's channel; an agent's in its own. */
234
252
  const turnChannelOf = (scope?: AgentTurnScope) =>
235
253
  scope?.group ? scope.session : scope?.home;
@@ -3,6 +3,7 @@
3
3
 
4
4
  export type { DiscordServices } from "../core/builtin/discord.ts";
5
5
  export { DISCORD } from "../core/builtin/discord.ts";
6
+ export type { DiscordAdapterConfig } from "../core/config/config.ts";
6
7
  export type { AgentPanelMessage } from "../core/discord/agent-commands.ts";
7
8
  export type {
8
9
  AgentPanel,
@@ -33,6 +34,7 @@ export type {
33
34
  ChannelInfo,
34
35
  DiscordConnection,
35
36
  } from "../core/discord/connection.ts";
37
+ export { discord } from "../core/discord/discord-adapter.ts";
36
38
  export type {
37
39
  CommandGuard,
38
40
  CommandRegistrar,
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ export {
21
21
  AGENT_TEAM_SERVICE,
22
22
  } from "./core/builtin/agent-server.ts";
23
23
  export type {
24
+ AdapterConfig,
25
+ DiscordConfig,
24
26
  Pronouns,
25
27
  RoundtableConfig,
26
28
  TierConfig,
@@ -57,6 +59,12 @@ export type { ServiceKey, Services } from "./core/contract/services.ts";
57
59
  export { serviceKey } from "./core/contract/services.ts";
58
60
  export type { ChatSurface, SurfacePort } from "./core/contract/surface.ts";
59
61
  export { channelKey, parseChannelKey } from "./core/contract/surface.ts";
62
+ export type {
63
+ ConversationRecord,
64
+ ConversationRegistration,
65
+ ConversationRegistry,
66
+ ConversationVisibility,
67
+ } from "./core/conversations/conversation-registry.ts";
60
68
  export type { Migration, MigrationReport } from "./core/db/migrations.ts";
61
69
  export { migrateDatabase } from "./core/db/migrations.ts";
62
70
  export type { ToolContribution, ToolSpec, ToolTurn } from "./core/define.ts";
@@ -101,6 +109,7 @@ export type {
101
109
  OwnerQuestion,
102
110
  } from "./core/domain/owner-prompts.ts";
103
111
  export type { TurnRequest } from "./core/domain/ports.ts";
112
+ export type { TurnProgress } from "./core/domain/progress.ts";
104
113
  export type { DrainOptions } from "./core/drain.ts";
105
114
  export {
106
115
  JudgeError,
@@ -117,6 +126,12 @@ export type {
117
126
  ListenerAddress,
118
127
  ListenerConfig,
119
128
  } from "./core/http/listeners.ts";
129
+ export type {
130
+ RouteSocket,
131
+ WebSocketAccept,
132
+ WebSocketRoute,
133
+ WebSocketSendResult,
134
+ } from "./core/http/websocket.ts";
120
135
  export type { Locale } from "./core/i18n/index.ts";
121
136
  export type { JudgeModel } from "./core/judging/model-judge.ts";
122
137
  export type { LogEntry, LogFn, Logger } from "./core/log.ts";
@@ -177,6 +192,7 @@ export type {
177
192
  ServiceStartOutcome,
178
193
  TurnEndEvent,
179
194
  TurnEvent,
195
+ TurnProgressEvent,
180
196
  } from "./core/plugin.ts";
181
197
  export {
182
198
  attachReplyFile,
@@ -205,9 +221,11 @@ export type {
205
221
  export {
206
222
  AGENTS,
207
223
  BACKGROUND_TURNS,
224
+ CONVERSATIONS,
208
225
  DELEGATION,
209
226
  MEMORY,
210
227
  PRECHECKS,
228
+ RUNTIME,
211
229
  SCHEDULES,
212
230
  SKILLS,
213
231
  } from "./core/services.ts";
package/src/testing.ts CHANGED
@@ -44,6 +44,7 @@ import type {
44
44
  RoundtablePlugin,
45
45
  TurnEndEvent,
46
46
  TurnEvent,
47
+ TurnProgressEvent,
47
48
  } from "./core/plugin.ts";
48
49
  import {
49
50
  collectContributions,
@@ -63,7 +64,13 @@ import {
63
64
  conversationTurns,
64
65
  } from "./core/routing/conversation-turns.ts";
65
66
  import { surfacePort } from "./core/routing/surface-port.ts";
66
- import { AGENTS, type AgentServer, BACKGROUND_TURNS } from "./core/services.ts";
67
+ import {
68
+ AGENTS,
69
+ type AgentServer,
70
+ BACKGROUND_TURNS,
71
+ CONVERSATIONS,
72
+ RUNTIME,
73
+ } from "./core/services.ts";
67
74
  import type { ChannelKey, SessionContext } from "./core/sessions.ts";
68
75
  import { type Speaker, speakerPolicy } from "./core/speakers.ts";
69
76
  import { type ToolTierTable, toolTiers } from "./core/tool-tiers.ts";
@@ -99,6 +106,15 @@ export type {
99
106
  RecordingLogger,
100
107
  } from "./core/testing/recording-logger.ts";
101
108
  export { recordingLogger } from "./core/testing/recording-logger.ts";
109
+ export type {
110
+ SurfaceContractFailure,
111
+ SurfaceContractSubject,
112
+ SurfaceObservation,
113
+ } from "./core/testing/surface-contract.ts";
114
+ export {
115
+ checkSurfaceContract,
116
+ describeSurfaceContract,
117
+ } from "./core/testing/surface-contract.ts";
102
118
  export type { TestHost, TestHostOptions } from "./core/testing/test-host.ts";
103
119
  export { testHost } from "./core/testing/test-host.ts";
104
120
  export type { FakeThreadHost } from "./core/testing/thread-host.ts";
@@ -177,8 +193,10 @@ export interface TestPluginOptions {
177
193
  surfaces?: readonly ChatSurface[];
178
194
  /**
179
195
  * What the plugin reads from `context.services`, one `servicePair(KEY, { ... })` per service.
180
- * `servicePair(AGENTS, { runtime })` is the runtime that `context.turns` runs turns on; without
181
- * it, a plugin that fills the `runtime` slot gets the runtime its provider builds. A service
196
+ * `servicePair(RUNTIME, runtime)` is the runtime that `context.turns` runs turns on, given
197
+ * whole rather than member by member; `servicePair(AGENTS, { runtime })` still works when no
198
+ * `RUNTIME` is given. Without either, a plugin that fills the `runtime` slot gets the runtime its
199
+ * provider builds, under both keys. A service
182
200
  * not given here reads as absent to `find`, and `get` names this option, except for what the
183
201
  * host supplies anyway: `BACKGROUND_TURNS`, the real background turns over the test's router,
184
202
  * and, once `AGENTS` is given, its `approvals` (the real confirmation judge over
@@ -323,6 +341,9 @@ export async function testPlugin(
323
341
  stop: (channel) => (router ?? unlinked("conversations")).stop(channel),
324
342
  postsInPlace: (channel) =>
325
343
  (router ?? unlinked("conversations")).postsInPlace(channel),
344
+ owns: (channel) => (router ?? unlinked("conversations")).owns(channel),
345
+ takesBackground: (channel) =>
346
+ (router ?? unlinked("conversations")).takesBackground(channel),
326
347
  ...options.conversations,
327
348
  };
328
349
  const surfaces = surfacePort(() =>
@@ -339,6 +360,8 @@ export async function testPlugin(
339
360
  events.push({ name: "turnEnded", turn });
340
361
  bus.sink.turnEnded(turn);
341
362
  },
363
+ // Delivered to the plugin's handlers, not recorded: a turn reports many.
364
+ turnProgress: (event: TurnProgressEvent) => bus.sink.turnProgress?.(event),
342
365
  changed: () => {
343
366
  events.push({ name: "changed" });
344
367
  bus.sink.changed();
@@ -350,7 +373,14 @@ export async function testPlugin(
350
373
  linked: () => {
351
374
  if (!linked) unlinked("turns");
352
375
  },
353
- runtime: () => services.get(AGENTS).runtime,
376
+ runtime: () => {
377
+ const given = services.find(RUNTIME);
378
+ if (given) return given;
379
+ const server = services.find(AGENTS);
380
+ return server ? server.runtime : services.get(RUNTIME);
381
+ },
382
+ // A test that gives `CONVERSATIONS` sees each turn record its conversation.
383
+ registry: () => services.find(CONVERSATIONS),
354
384
  surfaces,
355
385
  events: sink,
356
386
  selection: () => (linked ?? unlinked("sessions")).agentSelection(),
@@ -391,6 +421,8 @@ export async function testPlugin(
391
421
  };
392
422
  runtime = providers.runtime(deps);
393
423
  // The provider's runtime serves `context.turns` unless the test gave another.
424
+ if (!given.has(RUNTIME.id))
425
+ given.set(RUNTIME.id, { key: RUNTIME, given: runtime });
394
426
  if (!agents || !("runtime" in agents.given))
395
427
  given.set(AGENTS.id, {
396
428
  key: AGENTS,
@@ -411,8 +443,12 @@ export async function testPlugin(
411
443
  }),
412
444
  } satisfies Partial<AgentServer>,
413
445
  });
446
+ // A runtime is given whole: a class's private members do not survive the partial service's proxy.
414
447
  for (const { key, given: members } of given.values())
415
- services.preset(key, partialService(key, members));
448
+ services.preset(
449
+ key,
450
+ key.id === RUNTIME.id ? members : partialService(key, members),
451
+ );
416
452
  // The turns nobody wrote run through the same router, unless the test gave others or the plugin provides them.
417
453
  if (
418
454
  !given.has(BACKGROUND_TURNS.id) &&
@@ -0,0 +1,24 @@
1
+ # Copy this file to .env and fill it in. Bun loads .env by itself; never commit it.
2
+
3
+ # You: the operator who runs this host, named in the assistant's prompts.
4
+ OWNER_NAME=
5
+
6
+ # PostgreSQL. This matches docker-compose.yml: run `docker compose up -d`.
7
+ DATABASE_URL=postgres://roundtable:roundtable@localhost:5432/roundtable
8
+
9
+ # The assistant's model as <provider>/<id>, and the provider's API key (or log in with Pi and leave the key out).
10
+ MODEL=anthropic/claude-sonnet-5-5
11
+ # ANTHROPIC_API_KEY=
12
+
13
+ # Your OpenID Connect provider: register this API there, then copy its issuer (iss), the API's
14
+ # audience (aud), and the provider's signing keys address (jwks_uri in its discovery document).
15
+ OIDC_ISSUER=
16
+ OIDC_AUDIENCE=
17
+ OIDC_JWKS_URL=
18
+
19
+ # Who may chat: the role names your provider puts in the token's roles claim, comma-separated.
20
+ CHAT_MEMBER_ROLES=
21
+ # CHAT_ADMIN_ROLES=
22
+
23
+ # The origins of the web pages that open the chat, comma-separated, such as https://chat.example.com.
24
+ CHAT_ORIGINS=
@@ -0,0 +1,15 @@
1
+ # __PROJECT__
2
+
3
+ A web chat agent server built on [pi-roundtable](https://www.npmjs.com/package/pi-roundtable) and [pi-roundtable-webchat](https://www.npmjs.com/package/pi-roundtable-webchat).
4
+ People your OpenID Connect provider signs in chat with the assistant in private conversations; there is no Discord.
5
+
6
+ 1. Install: `bun install`, then copy `.env.example` to `.env` and fill it in.
7
+ 2. Check: `bunx roundtable doctor` says what is still missing and how to fix it.
8
+ 3. Run: `bunx roundtable start`, behind a reverse proxy that serves it over HTTPS.
9
+
10
+ The chat is under `/chat`: a web page gets a one-time ticket from `POST /chat/tickets` with the person's token, and opens `/chat/socket` with it.
11
+ The protocol is in `node_modules/pi-roundtable-webchat/README.md`.
12
+
13
+ `roundtable.config.ts` holds the settings and lists the plugins; `plugins/` holds yours, and `persona/assistant.md` is the assistant's prompt.
14
+ Add another plugin with `bunx roundtable add plugin <name>`, and test it with `bun test`.
15
+ The plugin guide is in `node_modules/pi-roundtable/docs/plugins.md` and on the package's page.