pi-roundtable 0.2.1 → 0.4.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 (46) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/README.zh-TW.md +2 -2
  4. package/docs/plugins.md +100 -5
  5. package/examples/shared-services.test.ts +28 -0
  6. package/examples/shared-services.ts +31 -0
  7. package/package.json +6 -3
  8. package/src/cli/add-plugin.ts +4 -3
  9. package/src/cli/cli.ts +3 -0
  10. package/src/cli/templates.ts +21 -4
  11. package/src/core/contract/services.ts +7 -0
  12. package/src/core/define-roundtable.ts +2 -0
  13. package/src/core/host.ts +7 -0
  14. package/src/core/http/listeners.ts +5 -25
  15. package/src/core/modules/delegation/web-research-worker.ts +14 -1
  16. package/src/core/plugin.ts +14 -0
  17. package/src/core/registry/contributions.ts +1 -0
  18. package/src/core/registry/services.ts +73 -1
  19. package/src/core/runtime/session-factory.ts +1 -1
  20. package/src/core/shared/package-dir.ts +8 -5
  21. package/src/core/shared/unix-server.ts +26 -0
  22. package/src/core/testing/partial.ts +21 -0
  23. package/src/core/testing/recording-logger.ts +44 -0
  24. package/src/core/testing/test-host.ts +14 -1
  25. package/src/discord/index.ts +1 -1
  26. package/src/kit/channels.ts +1 -1
  27. package/src/kit/domain.ts +1 -1
  28. package/src/kit/holds.ts +1 -1
  29. package/src/kit/index.ts +2 -1
  30. package/src/kit/judging.ts +1 -1
  31. package/src/kit/memory.ts +1 -1
  32. package/src/kit/mirror.ts +1 -1
  33. package/src/kit/presentation.ts +1 -1
  34. package/src/kit/process.ts +5 -0
  35. package/src/kit/shell.ts +1 -1
  36. package/src/kit/skills.ts +1 -1
  37. package/src/kit/support.ts +1 -1
  38. package/src/kit/threads.ts +1 -1
  39. package/src/kit/tools.ts +1 -1
  40. package/src/kit/worker.ts +1 -1
  41. package/src/testing.ts +16 -2
  42. package/templates/agents.ts +5 -0
  43. package/templates/official/codex-images/plugin.test.ts.tmpl +118 -0
  44. package/templates/official/codex-images/plugin.ts +215 -0
  45. package/templates/official/dice/plugin.test.ts.tmpl +105 -0
  46. package/templates/official/dice/plugin.ts +166 -0
@@ -6,6 +6,7 @@ import {
6
6
  SessionManager,
7
7
  SettingsManager,
8
8
  } from "@earendil-works/pi-coding-agent";
9
+ import { PluginError } from "../../errors.ts";
9
10
  import {
10
11
  formatModelRef,
11
12
  type ModelRef,
@@ -20,6 +21,18 @@ const WEB_TOOLS = ["web_search", "fetch_content", "get_search_content"];
20
21
  const WORKER_PROMPT =
21
22
  "You are a research worker. Another agent handed you the task below and will pass your report on. Do it with web search and page reading, preferring primary sources. Report in the task's language, self-contained, with a source link for each claim, and say what you could not verify.";
22
23
 
24
+ /** The folder of `pi-web-access`, a peer dependency the host installs, or a PluginError that says how. */
25
+ function webAccessDir(): string {
26
+ try {
27
+ return packageDir("pi-web-access");
28
+ } catch (error) {
29
+ throw new PluginError(
30
+ "the delegation worker loads pi-web-access, which this project does not have installed. It is a peer dependency of pi-roundtable: run `bun add pi-web-access@0.35.0` (or your own build of it, which the worker then uses too).",
31
+ { cause: error },
32
+ );
33
+ }
34
+ }
35
+
23
36
  export interface WebResearchWorkerOptions {
24
37
  /** Shared with the rest of the host, so the Codex login refreshes in one place. */
25
38
  modelRuntime: ModelRuntime;
@@ -36,7 +49,7 @@ export class WebResearchWorker {
36
49
 
37
50
  constructor(options: WebResearchWorkerOptions) {
38
51
  this.#options = options;
39
- this.#webAccessPath = packageDir("pi-web-access");
52
+ this.#webAccessPath = webAccessDir();
40
53
  mkdirSync(options.workDir, { recursive: true });
41
54
  }
42
55
 
@@ -268,6 +268,12 @@ export interface PluginContext {
268
268
  providers: ResolvedProviders;
269
269
  /** Every plugin's dashboard lines, in contribution order; throws NotLinkedError during setup. */
270
270
  dashboard(): readonly string[];
271
+ /**
272
+ * The credential the host's model login holds for a provider, such as `openai-codex`: the same
273
+ * login the agents use. Resolves to `undefined` when the host has none for that provider, and
274
+ * never throws for that. The value is a secret: keep it out of logs and error messages.
275
+ */
276
+ apiKey(provider: string): Promise<string | undefined>;
271
277
  }
272
278
 
273
279
  export interface RoundtablePlugin {
@@ -281,6 +287,14 @@ export interface RoundtablePlugin {
281
287
  * list before any setup, and refuses the plugin when setup returns without providing one.
282
288
  */
283
289
  provides?: readonly ServiceKey<unknown>[];
290
+ /**
291
+ * The services this plugin's setup reads with `get`, so the host can check them before any
292
+ * setup and any migration: a key no registered plugin provides, or one provided by a plugin
293
+ * registered after this one, is a PluginError that names both plugins and the fix. A service
294
+ * read only after startup, from a callback, is `services.lazy` instead, which does not depend
295
+ * on order. Leave out a service read with `find`, since that one may be absent.
296
+ */
297
+ requires?: readonly ServiceKey<unknown>[];
284
298
  /**
285
299
  * Services this plugin replaces. The host drops the plugin that provides them and sets this one
286
300
  * up where it stood, so it may read what the plugins before that place provide and nothing
@@ -385,6 +385,7 @@ export async function collectContributions(
385
385
  registry.prompt.push(...prompt);
386
386
  registry.requiredTools.push(...requiredTools);
387
387
  }
388
+ services.settle();
388
389
  return registry;
389
390
  }
390
391
 
@@ -1,5 +1,5 @@
1
1
  import type { ServiceKey, Services } from "../contract/services.ts";
2
- import { PluginError } from "../errors.ts";
2
+ import { NotLinkedError, PluginError } from "../errors.ts";
3
3
  import type { RoundtablePlugin } from "../plugin.ts";
4
4
 
5
5
  /** The host's words for reading a service no plugin declares; `testPlugin` says how to give one instead. */
@@ -29,6 +29,22 @@ function declaredBy(plugin: RoundtablePlugin): readonly ServiceKey<unknown>[] {
29
29
  return provides;
30
30
  }
31
31
 
32
+ /** The services a plugin requires, checked to be a list of keys; empty when it requires none. */
33
+ function requiredBy(plugin: RoundtablePlugin): readonly ServiceKey<unknown>[] {
34
+ const { requires } = plugin;
35
+ if (requires === undefined) return [];
36
+ if (!Array.isArray(requires))
37
+ throw new PluginError(
38
+ `plugin ${plugin.name}: requires must be a list of service keys made with serviceKey().`,
39
+ );
40
+ for (const key of requires)
41
+ if (typeof key?.id !== "string" || key.id === "")
42
+ throw new PluginError(
43
+ `plugin ${plugin.name}: requires has an entry that is not a service key; make each with serviceKey("<id>").`,
44
+ );
45
+ return requires;
46
+ }
47
+
32
48
  /** The services a plugin replaces, checked to be a list of keys; empty when it replaces none. */
33
49
  function replacedBy(plugin: RoundtablePlugin): readonly ServiceKey<unknown>[] {
34
50
  const { replaces } = plugin;
@@ -127,10 +143,16 @@ export class ServiceRegistry {
127
143
  #setting: RoundtablePlugin | undefined;
128
144
  /** The plugins that have read a service, such as one that adds its commands through it. */
129
145
  readonly #readers = new Set<RoundtablePlugin>();
146
+ readonly #plugins: readonly RoundtablePlugin[];
147
+ /** What each plugin asked `lazy` for, to refuse a key nobody provides once every plugin is set up. */
148
+ readonly #lazy: { plugin: RoundtablePlugin; key: ServiceKey<unknown> }[] = [];
149
+ /** Whether every plugin is set up, from which a `lazy` reader returns its service. */
150
+ #settled = false;
130
151
 
131
152
  /** `advice` says how to get a service no plugin declares; the host's is to register one that does. */
132
153
  constructor(plugins: readonly RoundtablePlugin[], advice = HOST_ADVICE) {
133
154
  this.#advice = advice;
155
+ this.#plugins = plugins;
134
156
  for (const plugin of plugins)
135
157
  for (const key of declaredBy(plugin)) {
136
158
  const other = this.#declaredBy.get(key.id);
@@ -147,6 +169,45 @@ export class ServiceRegistry {
147
169
  this.#values.set(key.id, value);
148
170
  }
149
171
 
172
+ /**
173
+ * Refuses a plugin whose `requires` names a service no registered plugin or test provides, or
174
+ * one a plugin registered after it provides. It runs before any migration or setup, so a wrong
175
+ * order fails at the start with both plugins named.
176
+ */
177
+ checkRequires(): void {
178
+ this.#plugins.forEach((plugin, index) => {
179
+ for (const key of requiredBy(plugin)) {
180
+ if (this.#values.has(key.id)) continue;
181
+ const provider = this.#declaredBy.get(key.id);
182
+ if (!provider)
183
+ throw new PluginError(
184
+ `plugin ${plugin.name}: requires service ${key.id}, which no registered plugin provides. ${key.absent ?? this.#advice}`,
185
+ );
186
+ if (provider === plugin)
187
+ throw new PluginError(
188
+ `plugin ${plugin.name}: requires service ${key.id}, which it provides itself. A plugin cannot require what it provides.`,
189
+ );
190
+ if (this.#plugins.indexOf(provider) > index)
191
+ throw new PluginError(
192
+ `plugin ${plugin.name}: requires service ${key.id}, which plugin ${provider.name} provides after it. Register plugin ${provider.name} before plugin ${plugin.name}, or read the service with services.lazy(KEY) from a callback that runs after startup.`,
193
+ );
194
+ }
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Called once every plugin is set up: `lazy` readers start answering, and a key a plugin asked
200
+ * for that nobody provides is refused with the plugin named.
201
+ */
202
+ settle(): void {
203
+ for (const { plugin, key } of this.#lazy)
204
+ if (!this.#values.has(key.id))
205
+ throw new PluginError(
206
+ `plugin ${plugin.name}: services.lazy reads service ${key.id}, which no registered plugin provides. ${key.absent ?? this.#advice}`,
207
+ );
208
+ this.#settled = true;
209
+ }
210
+
150
211
  /** The service, for the host's own reads; throws like a plugin's `get`. */
151
212
  get<T>(key: ServiceKey<T>): T {
152
213
  return this.#read(key, undefined, true) as T;
@@ -185,6 +246,17 @@ export class ServiceRegistry {
185
246
  this.#readers.add(plugin);
186
247
  return this.#read(key, plugin, false);
187
248
  },
249
+ lazy: <T>(key: ServiceKey<T>) => {
250
+ this.#readers.add(plugin);
251
+ this.#lazy.push({ plugin, key });
252
+ return () => {
253
+ if (!this.#settled)
254
+ throw new NotLinkedError(
255
+ `service ${key.id} is read through lazy() once every plugin is set up. Call it from a service's start or from a handler, not during setup.`,
256
+ );
257
+ return this.#values.get(key.id) as T;
258
+ };
259
+ },
188
260
  provide: (key, value) => {
189
261
  if (this.#setting !== plugin)
190
262
  throw new PluginError(
@@ -77,7 +77,7 @@ export class SessionFactory {
77
77
  const linked = this.#options.sessions();
78
78
  this.#linked = {
79
79
  ...linked,
80
- extensionPaths: linked.piPackages.map(packageDir),
80
+ extensionPaths: linked.piPackages.map((name) => packageDir(name)),
81
81
  };
82
82
  }
83
83
  return this.#linked;
@@ -1,9 +1,12 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { dirname } from "node:path";
3
3
 
4
- const require = createRequire(import.meta.url);
5
-
6
- /** The installed package's folder, as Pi's `additionalExtensionPaths` takes it. */
7
- export function packageDir(name: string): string {
8
- return dirname(require.resolve(`${name}/package.json`));
4
+ /**
5
+ * The installed package's folder, as Pi's `additionalExtensionPaths` takes it. The package is
6
+ * looked up from `from`, which is `import.meta.url` of the module that asks, so a host finds its
7
+ * own dependencies even when `pi-roundtable` is linked or installed apart from them; without it
8
+ * the lookup starts at the core's own files.
9
+ */
10
+ export function packageDir(name: string, from: string | URL = import.meta.url) {
11
+ return dirname(createRequire(from).resolve(`${name}/package.json`));
9
12
  }
@@ -0,0 +1,26 @@
1
+ import { rmSync } from "node:fs";
2
+ import type { Server } from "bun";
3
+
4
+ /**
5
+ * Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
6
+ * turns and quiet model streams. Bun 1.4.2 honors `idleTimeout` on unix sockets, but its
7
+ * types reject the option there, hence the cast. A stale socket file is removed first.
8
+ * `error` answers a failure raised outside the fetch handler; without it Bun's own page is sent.
9
+ */
10
+ export function serveUnix(
11
+ socketPath: string,
12
+ fetch: (request: Request) => Response | Promise<Response>,
13
+ options: { error?: (error: Error) => Response | Promise<Response> } = {},
14
+ ): Server<undefined> {
15
+ rmSync(socketPath, { force: true });
16
+ const serveOptions = {
17
+ unix: socketPath,
18
+ idleTimeout: 0,
19
+ fetch,
20
+ ...(options.error ? { error: options.error } : {}),
21
+ };
22
+ // SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
23
+ return Bun.serve(
24
+ serveOptions as unknown as Parameters<typeof Bun.serve>[0],
25
+ ) as Server<undefined>;
26
+ }
@@ -0,0 +1,21 @@
1
+ /** Members a probe may ask of any object: a test's stand-in is not refused for them. */
2
+ const PROBED = new Set(["then", "toJSON", "asymmetricMatch"]);
3
+
4
+ /**
5
+ * A stand-in for a port the code under test takes, made of only the members the test gives.
6
+ * Reading any other member throws an error that names it, so a test learns at once which member
7
+ * the code reaches for, instead of meeting `undefined is not a function` or a hidden cast.
8
+ * `partial<AgentTeam>({ announce })` is an `AgentTeam` to the type checker.
9
+ */
10
+ export function partial<T extends object>(given: Partial<T>): T {
11
+ // SAFETY: the stand-in answers only the members given; any other read throws before it is used.
12
+ return new Proxy(given, {
13
+ get(target, member, receiver) {
14
+ if (member in target || typeof member === "symbol" || PROBED.has(member))
15
+ return Reflect.get(target, member, receiver);
16
+ throw new Error(
17
+ `partial() was given no "${member}". Give it where the stand-in is made: partial({ ${member}: ... }).`,
18
+ );
19
+ },
20
+ }) as T;
21
+ }
@@ -0,0 +1,44 @@
1
+ import type { Logger } from "../log.ts";
2
+
3
+ /** One line a recording logger kept. */
4
+ export interface RecordedLog {
5
+ level: "debug" | "info" | "warn" | "error" | "fatal";
6
+ /** The fields of the call, with those of every `child` the logger came from. */
7
+ fields: Record<string, unknown>;
8
+ message: string;
9
+ }
10
+
11
+ /** A logger that keeps what it is asked to write, and the lines it kept. */
12
+ export interface RecordingLogger {
13
+ readonly logger: Logger;
14
+ /** Every line written through the logger or a child of it, in order. */
15
+ readonly lines: RecordedLog[];
16
+ }
17
+
18
+ /**
19
+ * A logger for a test that checks what the code logs: it writes nowhere, and `lines` holds each
20
+ * call with its level, fields (a child's included), and message.
21
+ */
22
+ export function recordingLogger(): RecordingLogger {
23
+ const lines: RecordedLog[] = [];
24
+ const make = (bound: Record<string, unknown>): Logger => {
25
+ const write =
26
+ (level: RecordedLog["level"]) =>
27
+ (first: object | string, message?: string): void => {
28
+ lines.push(
29
+ typeof first === "string"
30
+ ? { level, fields: { ...bound }, message: first }
31
+ : { level, fields: { ...bound, ...first }, message: message ?? "" },
32
+ );
33
+ };
34
+ return {
35
+ debug: write("debug"),
36
+ info: write("info"),
37
+ warn: write("warn"),
38
+ error: write("error"),
39
+ fatal: write("fatal"),
40
+ child: (fields) => make({ ...bound, ...fields }),
41
+ };
42
+ };
43
+ return { logger: make({}), lines };
44
+ }
@@ -38,6 +38,11 @@ export interface TestHostOptions {
38
38
  plugins?: readonly RoundtablePlugin[];
39
39
  /** The runtime every turn runs on; by default one that answers "" and builds no Pi session. */
40
40
  runtime?: AgentRuntime;
41
+ /**
42
+ * The credentials `context.apiKey` returns, by provider name; a provider not listed reads as
43
+ * having none, whatever login the machine holds.
44
+ */
45
+ apiKeys?: Readonly<Record<string, string>>;
41
46
  /** What the stand-in Discord hands out. */
42
47
  discord?: Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
43
48
  }
@@ -210,7 +215,15 @@ export async function testHost(
210
215
  ],
211
216
  };
212
217
  const defined = await defineRoundtable(config, { logger: silentLogger() });
213
- const roundtable = new Roundtable(defined.options, defined.plugins);
218
+ const apiKeys = options.apiKeys ?? {};
219
+ const roundtable = new Roundtable(
220
+ {
221
+ ...defined.options,
222
+ apiKey: async (provider) =>
223
+ Object.hasOwn(apiKeys, provider) ? apiKeys[provider] : undefined,
224
+ },
225
+ defined.plugins,
226
+ );
214
227
  await roundtable.run();
215
228
  if (!captured) throw new Error("the probe was not set up");
216
229
  const context = captured;
@@ -1,5 +1,5 @@
1
1
  // The Discord entry: everything that names a discord.js type, apart from the main and kit entries.
2
- // Unstable before 1.0; not covered by semver.
2
+ // Versioned like the main entry: a breaking change comes in a minor release before 1.0 and is listed in the changelog.
3
3
 
4
4
  export type { DiscordServices } from "../core/builtin/discord.ts";
5
5
  export { DISCORD } from "../core/builtin/discord.ts";
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
 
3
3
  export { discordKey } from "../core/agents/team-keys.ts";
4
4
 
package/src/kit/domain.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
 
3
3
  export { AgentError } from "../core/domain/errors.ts";
4
4
  export type {
package/src/kit/holds.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
  // The chain a host links from every plugin's hold rules, to test a rule set as the host runs it.
3
3
 
4
4
  export { holdChain } from "../core/holds.ts";
package/src/kit/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Unstable before 1.0; not covered by semver. Helpers for owner commands, claims, and naming existing core parts.
1
+ // Helpers for owner commands, claims, and naming existing core parts; versioned like the main entry.
2
2
 
3
3
  export type { ChannelQueue } from "./channels.ts";
4
4
  export {
@@ -62,6 +62,7 @@ export {
62
62
  thinkingLine,
63
63
  zonedStamp,
64
64
  } from "./presentation.ts";
65
+ export { packageDir, serveUnix } from "./process.ts";
65
66
  export {
66
67
  SHELL_TOOLS,
67
68
  shellHoldRule,
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
 
3
3
  export type {
4
4
  EffortBrief,
package/src/kit/memory.ts CHANGED
@@ -1,3 +1,3 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
 
3
3
  export { searchTerms } from "../core/modules/memory/owner-memory-store.ts";
package/src/kit/mirror.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
  // Mirror a built-in tool in a worker that cannot reach the host: the specs and the dispatcher.
3
3
 
4
4
  export type { ScheduleToolContext } from "../core/modules/schedules/schedule-tools.ts";
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
 
3
3
  export { headline } from "../core/presentation/headline.ts";
4
4
  export { quietLinks } from "../core/presentation/quiet-links.ts";
@@ -0,0 +1,5 @@
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
+ // Running a worker process of your own: where an installed package lives, and a unix-socket HTTP server.
3
+
4
+ export { packageDir } from "../core/shared/package-dir.ts";
5
+ export { serveUnix } from "../core/shared/unix-server.ts";
package/src/kit/shell.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
  // The host-shell tools and the hold rule that keeps risky commands behind the owner's approval.
3
3
 
4
4
  export {
package/src/kit/skills.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
  // Skills and repositories: the names a repository shelf accepts, and the tool that lists skills.
3
3
 
4
4
  export { checkRepoName } from "../core/modules/skills/repo-name.ts";
@@ -1,4 +1,4 @@
1
- // Supporting data types for the unstable service and helper contracts.
1
+ // Supporting data types for the service and helper contracts.
2
2
 
3
3
  export type {
4
4
  AgentCategory,
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
 
3
3
  export type {
4
4
  DispatchThread,
package/src/kit/tools.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
 
3
3
  export type {
4
4
  TextToolDef,
package/src/kit/worker.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Unstable plugin helpers; see the plugin guide.
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
  // Running a Pi session of your own: MCP servers for it, its work timeout, its held-action cards.
3
3
 
4
4
  export {
package/src/testing.ts CHANGED
@@ -63,7 +63,6 @@ import { type ToolTierTable, toolTiers } from "./core/tool-tiers.ts";
63
63
  // Fixtures for plugin tests.
64
64
 
65
65
  export { silentLogger } from "./core/log.ts";
66
-
67
66
  export type { TestStore } from "./core/testing/database.ts";
68
67
  export {
69
68
  describeDb,
@@ -74,8 +73,13 @@ export {
74
73
  export { eagerText, useEagerCatalog } from "./core/testing/eager-catalog.ts";
75
74
  export type { TestLocale } from "./core/testing/locale.ts";
76
75
  export { useTestLocale } from "./core/testing/locale.ts";
77
-
78
76
  export { OWNER_SPEAKER } from "./core/testing/owner.ts";
77
+ export { partial } from "./core/testing/partial.ts";
78
+ export type {
79
+ RecordedLog,
80
+ RecordingLogger,
81
+ } from "./core/testing/recording-logger.ts";
82
+ export { recordingLogger } from "./core/testing/recording-logger.ts";
79
83
  export type { TestHost, TestHostOptions } from "./core/testing/test-host.ts";
80
84
  export { testHost } from "./core/testing/test-host.ts";
81
85
  export type { FakeThreadHost } from "./core/testing/thread-host.ts";
@@ -170,6 +174,11 @@ export interface TestPluginOptions {
170
174
  conversations?: Partial<ConversationPort>;
171
175
  /** Replaces `context.turns`, which by default runs turns over the runtime and the surfaces. */
172
176
  turns?: ConversationTurns;
177
+ /**
178
+ * The credentials `context.apiKey` returns, by provider name; a provider not listed reads as
179
+ * having none, as on a host that is not logged in to it.
180
+ */
181
+ apiKeys?: Readonly<Record<string, string>>;
173
182
  /** How long the router holds a bare forward for the message that follows it; the host option `conversations.forwardJoinMs`. */
174
183
  forwardJoinMs?: number;
175
184
  }
@@ -393,6 +402,7 @@ export async function testPlugin(
393
402
  logger,
394
403
  }),
395
404
  );
405
+ services.checkRequires();
396
406
  const context: Omit<PluginContext, "services"> = {
397
407
  logger,
398
408
  env,
@@ -409,6 +419,10 @@ export async function testPlugin(
409
419
  },
410
420
  providers,
411
421
  dashboard: () => (linked ? registry.dashboard : unlinked("dashboard")),
422
+ apiKey: async (provider) =>
423
+ Object.hasOwn(options.apiKeys ?? {}, provider)
424
+ ? options.apiKeys?.[provider]
425
+ : undefined,
412
426
  };
413
427
  const registry = await collectContributions(
414
428
  [plugin],
@@ -1,5 +1,8 @@
1
1
  import type { AgentSeed } from "pi-roundtable";
2
2
 
3
+ // Ids come from .env, which Bun loads on its own.
4
+ const env = (name: string): string => process.env[name] ?? "";
5
+
3
6
  // The first team. An agent that is already stored is never overwritten, so edit agents in Discord afterwards.
4
7
  export const agents: AgentSeed[] = [
5
8
  {
@@ -8,5 +11,7 @@ export const agents: AgentSeed[] = [
8
11
  prompt:
9
12
  "You are Guide, a friendly assistant. Answer briefly and ask when a request is unclear.",
10
13
  avatarPrompt: "A friendly lighthouse keeper with a warm lantern",
14
+ // The agent in the entry channel is the coordinator: it answers there and can create the others.
15
+ channelId: env("DISCORD_ENTRY_CHANNEL_ID"),
11
16
  },
12
17
  ];
@@ -0,0 +1,118 @@
1
+ import { expect, test } from "bun:test";
2
+ import { PluginError } from "pi-roundtable";
3
+ import { testPlugin } from "pi-roundtable/testing";
4
+ import {
5
+ type CodexFetch,
6
+ codexImages,
7
+ createCodexImages,
8
+ ImageNotGeneratedError,
9
+ } from "./codex-images.ts";
10
+
11
+ /** A login token shaped like the real one: a JWT whose claims name the ChatGPT account. */
12
+ function token(account: string): string {
13
+ const claims = {
14
+ "https://api.openai.com/auth": { chatgpt_account_id: account },
15
+ };
16
+ return `header.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.signature`;
17
+ }
18
+
19
+ const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
20
+
21
+ /** A server-sent event stream of the given events. */
22
+ function stream(...events: unknown[]): Response {
23
+ const text = events
24
+ .map((event) => `data: ${JSON.stringify(event)}\n\n`)
25
+ .join("");
26
+ return new Response(text, {
27
+ headers: { "content-type": "text/event-stream" },
28
+ });
29
+ }
30
+
31
+ const drawn = (result: string, status = "completed") => ({
32
+ type: "response.output_item.done",
33
+ item: { type: "image_generation_call", status, result },
34
+ });
35
+
36
+ const completed = { type: "response.completed", response: { output: [] } };
37
+
38
+ /** The plugin over a fake network that answers every request with `answer`, recording the requests. */
39
+ function over(answer: () => Response) {
40
+ const requests: { url: string; init: RequestInit }[] = [];
41
+ const fetch: CodexFetch = async (url, init) => {
42
+ requests.push({ url, init });
43
+ return answer();
44
+ };
45
+ return { plugin: createCodexImages({ fetch }), requests };
46
+ }
47
+
48
+ test("codex-images fills the images slot with the image Codex returns", async () => {
49
+ const secret = token("acct-1");
50
+ const { plugin, requests } = over(() =>
51
+ stream(drawn(Buffer.from(PNG).toString("base64")), completed),
52
+ );
53
+ const harness = await testPlugin(plugin, {
54
+ apiKeys: { "openai-codex": secret },
55
+ });
56
+ const image = await plugin.providers?.images?.("a fox", [
57
+ { data: "AAAA", mimeType: "image/png" },
58
+ ]);
59
+ expect([...(image ?? [])]).toEqual([...PNG]);
60
+ const [request] = requests;
61
+ expect(request?.url).toBe("https://chatgpt.com/backend-api/codex/responses");
62
+ const headers = request?.init.headers as Record<string, string>;
63
+ expect(headers.authorization).toBe(`Bearer ${secret}`);
64
+ expect(headers["chatgpt-account-id"]).toBe("acct-1");
65
+ const body = JSON.parse(String(request?.init.body));
66
+ expect(body.tools).toEqual([
67
+ { type: "image_generation", output_format: "png" },
68
+ ]);
69
+ expect(body.input[0].content).toEqual([
70
+ { type: "input_text", text: "a fox" },
71
+ { type: "input_image", image_url: "data:image/png;base64,AAAA" },
72
+ ]);
73
+ await harness.stop();
74
+ });
75
+
76
+ test("codex-images says so when Codex runs the tool and returns no image", async () => {
77
+ const refused = over(() => stream(drawn("", "failed"), completed));
78
+ const harness = await testPlugin(refused.plugin, {
79
+ apiKeys: { "openai-codex": token("acct-1") },
80
+ });
81
+ await expect(refused.plugin.providers?.images?.("a fox", [])).rejects.toThrow(
82
+ ImageNotGeneratedError,
83
+ );
84
+ await harness.stop();
85
+ const silent = over(() => stream(completed));
86
+ const quiet = await testPlugin(silent.plugin, {
87
+ apiKeys: { "openai-codex": token("acct-1") },
88
+ });
89
+ await expect(silent.plugin.providers?.images?.("a fox", [])).rejects.toThrow(
90
+ "Codex answered without an image",
91
+ );
92
+ await quiet.stop();
93
+ });
94
+
95
+ test("codex-images reports an HTTP error with its status, and never the token", async () => {
96
+ const secret = token("acct-1");
97
+ const { plugin } = over(() => new Response("rate limited", { status: 429 }));
98
+ const harness = await testPlugin(plugin, {
99
+ apiKeys: { "openai-codex": secret },
100
+ });
101
+ const failure = await plugin.providers?.images?.("a fox", []).then(
102
+ () => undefined,
103
+ (error: Error) => error,
104
+ );
105
+ expect(failure?.message).toBe("Codex answered 429: rate limited");
106
+ expect(failure?.message).not.toContain(secret);
107
+ await harness.stop();
108
+ });
109
+
110
+ test("codex-images refuses to start without an openai-codex login", async () => {
111
+ const failure = await testPlugin(codexImages).then(
112
+ () => undefined,
113
+ (error: unknown) => error,
114
+ );
115
+ expect(failure).toBeInstanceOf(PluginError);
116
+ expect((failure as PluginError).message).toContain("openai-codex");
117
+ expect((failure as PluginError).message).toContain("Log in");
118
+ });