pi-roundtable 0.16.0 → 0.17.1

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 (38) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +5 -0
  3. package/docs/plugins.md +69 -0
  4. package/package.json +1 -1
  5. package/src/cli/checks/configuration.ts +21 -1
  6. package/src/cli/project.ts +59 -8
  7. package/src/cli/runtime.ts +3 -0
  8. package/src/core/agents/agent-store.ts +4 -1
  9. package/src/core/builtin/discord.ts +5 -0
  10. package/src/core/config/adapters.ts +12 -0
  11. package/src/core/contract/runtime.ts +7 -0
  12. package/src/core/contract/surface.ts +7 -0
  13. package/src/core/db/check-database.ts +39 -0
  14. package/src/core/define-roundtable.ts +3 -0
  15. package/src/core/discord/discord-surface.ts +5 -0
  16. package/src/core/discord/formatting.ts +20 -0
  17. package/src/core/host-registration.ts +292 -0
  18. package/src/core/host.ts +45 -227
  19. package/src/core/identity/config-owner.ts +34 -0
  20. package/src/core/identity/identity-plugin.ts +7 -2
  21. package/src/core/identity/identity-service.ts +8 -1
  22. package/src/core/identity/plugin-identities.ts +19 -4
  23. package/src/core/modules/skills/skill-store.ts +3 -1
  24. package/src/core/runtime/extensions/surface-formatting.ts +22 -0
  25. package/src/core/runtime/pi-agent-runtime.ts +22 -21
  26. package/src/core/runtime/running-turn.ts +19 -0
  27. package/src/core/runtime/runtime-plugin.ts +4 -0
  28. package/src/core/runtime/runtime-types.ts +23 -2
  29. package/src/core/runtime/session-factory.ts +7 -0
  30. package/src/core/runtime/turn-budget.ts +112 -0
  31. package/src/core/testing/database.ts +20 -2
  32. package/src/core/testing/postgres.ts +160 -0
  33. package/src/core/testing/run-tool.ts +110 -0
  34. package/src/core/testing/test-host.ts +78 -30
  35. package/src/index.ts +1 -0
  36. package/src/testing.ts +25 -56
  37. package/templates/official/codex-images/plugin.test.ts.tmpl +29 -1
  38. package/templates/official/codex-images/plugin.ts +19 -2
@@ -0,0 +1,160 @@
1
+ import { afterAll } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { SQL } from "bun";
7
+ import { adoptTestDatabase } from "./database.ts";
8
+
9
+ export interface UsePostgresOptions {
10
+ /** Directories searched for initdb and postgres before PATH; default PG_BIN and the usual Homebrew and Debian paths. */
11
+ binDirs?: readonly string[];
12
+ /** Throw when initdb or postgres is not found; default false (the tests that need it skip or call requireDatabase). */
13
+ require?: boolean;
14
+ }
15
+
16
+ /** Where a PostgreSQL install usually keeps initdb and postgres, newest first. */
17
+ const USUAL_DIRS = [
18
+ ...[17, 16, 15, 14].map(
19
+ (major) => `/opt/homebrew/opt/postgresql@${major}/bin`,
20
+ ),
21
+ ...[17, 16, 15, 14].map((major) => `/usr/lib/postgresql/${major}/bin`),
22
+ "/usr/local/bin",
23
+ "/usr/bin",
24
+ ];
25
+
26
+ const READY_TIMEOUT_MS = 20_000;
27
+ const STOP_TIMEOUT_MS = 10_000;
28
+
29
+ function binary(name: string, dirs: readonly string[]): string | undefined {
30
+ for (const dir of dirs) {
31
+ const path = join(dir, name);
32
+ if (existsSync(path)) return path;
33
+ }
34
+ return Bun.which(name) ?? undefined;
35
+ }
36
+
37
+ /** A TCP port nothing listens on, found by binding port 0 and letting it go. */
38
+ async function freePort(): Promise<number> {
39
+ const probe = Bun.serve({
40
+ port: 0,
41
+ hostname: "127.0.0.1",
42
+ fetch: () => new Response(),
43
+ });
44
+ const port = probe.port ?? 0;
45
+ await probe.stop(true);
46
+ return port;
47
+ }
48
+
49
+ /** Waits until the server at `url` answers a query, or throws when it exited or the wait ran out. */
50
+ async function untilReady(url: string, server: Bun.Subprocess): Promise<void> {
51
+ const deadline = Date.now() + READY_TIMEOUT_MS;
52
+ for (;;) {
53
+ if (server.exitCode !== null)
54
+ throw new Error(
55
+ `the test PostgreSQL exited with code ${server.exitCode} before it was ready`,
56
+ );
57
+ const sql = new SQL(url, { max: 1 });
58
+ try {
59
+ await sql`SELECT 1`;
60
+ await sql.close();
61
+ return;
62
+ } catch {
63
+ await sql.close().catch(() => undefined);
64
+ if (Date.now() > deadline)
65
+ throw new Error(
66
+ `the test PostgreSQL did not become ready in ${READY_TIMEOUT_MS / 1000} seconds`,
67
+ );
68
+ await Bun.sleep(100);
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * For a test preload: unless ROUNDTABLE_TEST_DATABASE_URL is set, starts a throwaway PostgreSQL on
75
+ * 127.0.0.1 (trust auth, fsync off, a temporary data directory), sets the variable and
76
+ * `testDatabaseUrl`, and stops and removes it after the run (bun's global afterAll, exit and signals).
77
+ */
78
+ export async function usePostgresForTests(
79
+ options: UsePostgresOptions = {},
80
+ ): Promise<void> {
81
+ const given = process.env.ROUNDTABLE_TEST_DATABASE_URL;
82
+ if (given) {
83
+ adoptTestDatabase(given);
84
+ return;
85
+ }
86
+ const dirs = options.binDirs ?? [process.env.PG_BIN ?? "", ...USUAL_DIRS];
87
+ const initdb = binary("initdb", dirs.filter(Boolean));
88
+ const postgres = binary("postgres", dirs.filter(Boolean));
89
+ if (!initdb || !postgres) {
90
+ if (options.require)
91
+ throw new Error(
92
+ "this run needs PostgreSQL: install it so initdb and postgres are on PATH, set PG_BIN to its bin directory, or set ROUNDTABLE_TEST_DATABASE_URL to a throwaway database",
93
+ );
94
+ return;
95
+ }
96
+ const root = mkdtempSync(join(tmpdir(), "roundtable-test-pg-"));
97
+ const data = join(root, "data");
98
+ const init = spawnSync(
99
+ initdb,
100
+ ["-D", data, "-U", "postgres", "--auth=trust", "-E", "UTF8", "--no-sync"],
101
+ { encoding: "utf8" },
102
+ );
103
+ if (init.status !== 0) {
104
+ rmSync(root, { recursive: true, force: true });
105
+ throw new Error(`initdb failed: ${init.stderr}`);
106
+ }
107
+ const port = await freePort();
108
+ const server = Bun.spawn(
109
+ [
110
+ postgres,
111
+ "-D",
112
+ data,
113
+ "-p",
114
+ String(port),
115
+ "-k",
116
+ root,
117
+ "-c",
118
+ "listen_addresses=127.0.0.1",
119
+ "-c",
120
+ "fsync=off",
121
+ ],
122
+ { stdout: "ignore", stderr: "ignore" },
123
+ );
124
+ let stopped = false;
125
+ const stop = async () => {
126
+ if (stopped) return;
127
+ stopped = true;
128
+ // SIGINT is PostgreSQL's fast shutdown: sessions end and the server exits at once.
129
+ server.kill("SIGINT");
130
+ await Promise.race([server.exited, Bun.sleep(STOP_TIMEOUT_MS)]);
131
+ rmSync(root, { recursive: true, force: true });
132
+ };
133
+ // A global hook of the run: bun runs preload hooks once, after every test file.
134
+ afterAll(stop);
135
+ process.on("exit", () => {
136
+ if (stopped) return;
137
+ server.kill("SIGINT");
138
+ // A process that exits without the run's hooks leaves no data directory behind. The server
139
+ // writes while it shuts down, so removing it takes a few tries.
140
+ for (let attempt = 0; attempt < 100 && existsSync(root); attempt++) {
141
+ try {
142
+ rmSync(root, { recursive: true, force: true });
143
+ } catch {
144
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
145
+ }
146
+ }
147
+ });
148
+ for (const signal of ["SIGINT", "SIGTERM"] as const)
149
+ process.on(signal, () => {
150
+ void stop().then(() => process.exit(1));
151
+ });
152
+ const url = `postgres://postgres@127.0.0.1:${port}/postgres`;
153
+ try {
154
+ await untilReady(url, server);
155
+ } catch (error) {
156
+ await stop();
157
+ throw error;
158
+ }
159
+ adoptTestDatabase(url);
160
+ }
@@ -0,0 +1,110 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionFactory,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { ClientPart } from "../domain/client.ts";
6
+ import type { ReplyFile } from "../domain/conversation.ts";
7
+ import { PluginError } from "../errors.ts";
8
+ import {
9
+ attachReplyFile,
10
+ hasReplyFileScope,
11
+ withReplyFiles,
12
+ } from "../reply-files.ts";
13
+
14
+ /** What a session registers of a tool: the call the model's tool call reaches. */
15
+ export interface RegisteredTool {
16
+ name: string;
17
+ execute(
18
+ id: string,
19
+ params: Record<string, unknown>,
20
+ signal?: AbortSignal,
21
+ ): Promise<{
22
+ content: readonly {
23
+ type: string;
24
+ text?: string;
25
+ data?: string;
26
+ mimeType?: string;
27
+ }[];
28
+ isError?: boolean;
29
+ }>;
30
+ }
31
+
32
+ /** What the model would get back from one tool call, and what the call attached beside the text. */
33
+ export interface ToolCallResult {
34
+ text: string;
35
+ files: ReplyFile[];
36
+ images: ClientPart[];
37
+ }
38
+
39
+ /** Anything an extension may call or read on the Pi API besides registering a tool: accepted, does nothing. */
40
+ type Ignored = (...args: readonly never[]) => Ignored;
41
+
42
+ function ignored(): Ignored {
43
+ return new Proxy((() => ignored()) as Ignored, {
44
+ get: (target, key) => (key === "then" ? undefined : target),
45
+ });
46
+ }
47
+
48
+ /** The tools a factory registers, in order; every other registration (handlers, commands, events) is accepted and ignored. */
49
+ export async function registeredTools(
50
+ factory: ExtensionFactory | null | undefined,
51
+ ): Promise<RegisteredTool[]> {
52
+ const tools: RegisteredTool[] = [];
53
+ if (!factory) return tools;
54
+ // SAFETY: a session factory only registers tools, handlers and commands; the fake records the tools and ignores the rest.
55
+ const api = new Proxy(
56
+ {},
57
+ {
58
+ get: (_target, key) =>
59
+ key === "registerTool"
60
+ ? (definition: RegisteredTool) => tools.push(definition)
61
+ : ignored(),
62
+ },
63
+ ) as unknown as ExtensionAPI;
64
+ await factory(api);
65
+ return tools;
66
+ }
67
+
68
+ /**
69
+ * Runs the call as a turn on a surface that takes files or not runs it: the text, the files it
70
+ * attached and the images it returned. A file is also queued for the enclosing real turn when a
71
+ * fake runtime runs the call inside one. A tool that throws throws here; a refusal (an `isError` result) comes back as its text.
72
+ */
73
+ export async function callRegisteredTool(
74
+ tool: RegisteredTool,
75
+ args: Record<string, unknown>,
76
+ supportsFiles: boolean,
77
+ ): Promise<ToolCallResult> {
78
+ const images: ClientPart[] = [];
79
+ const result = await withReplyFiles(supportsFiles, async () => {
80
+ const output = await tool.execute("test-call", args);
81
+ for (const item of output.content)
82
+ if (item.type === "image" && item.data && item.mimeType)
83
+ images.push({
84
+ mimeType: item.mimeType,
85
+ data: new Uint8Array(Buffer.from(item.data, "base64")),
86
+ });
87
+ return {
88
+ ok: true,
89
+ text: output.content
90
+ .filter((item) => item.type === "text")
91
+ .map((item) => item.text ?? "")
92
+ .join("\n"),
93
+ };
94
+ });
95
+ if (!result.ok) throw result.error;
96
+ const files = result.files ?? [];
97
+ // When a tool is run by a fake runtime, forward into the enclosing real turn.
98
+ if (hasReplyFileScope()) for (const file of files) attachReplyFile(file);
99
+ return { text: result.text, files, images };
100
+ }
101
+
102
+ /** The registered tool called `name`, or the PluginError a test reads. */
103
+ export function toolNamed(
104
+ tools: readonly RegisteredTool[],
105
+ name: string,
106
+ ): RegisteredTool {
107
+ const found = tools.find((tool) => tool.name === name);
108
+ if (!found) throw new PluginError(`tool ${name} is not registered`);
109
+ return found;
110
+ }
@@ -1,11 +1,11 @@
1
- import { mkdtempSync } from "node:fs";
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import {
5
- type ExtensionAPI,
6
5
  type ExtensionFactory,
7
6
  SessionManager,
8
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
9
9
  import { DISCORD, type DiscordServices } from "../builtin/discord.ts";
10
10
  import { type RoundtableConfig, resolveConfig } from "../config/config.ts";
11
11
  import type { ConversationPort } from "../contract/channels.ts";
@@ -21,6 +21,7 @@ import type {
21
21
  InteractionContribution,
22
22
  } from "../discord/interaction-module.ts";
23
23
  import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
24
+ import type { HoldDescription } from "../holds.ts";
24
25
  import { Roundtable } from "../host.ts";
25
26
  import { silentLogger } from "../log.ts";
26
27
  import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
@@ -28,9 +29,15 @@ import { CompactionTiers } from "../runtime/compaction-tiers.ts";
28
29
  import { privateCompaction } from "../runtime/extensions/private-memory.ts";
29
30
  import { sessionExtensions } from "../runtime/runtime-types.ts";
30
31
  import { IDENTITY } from "../services.ts";
31
- import type { AgentTurnScope, SessionContext } from "../sessions.ts";
32
- import { THE_SPEAKER } from "../speakers.ts";
32
+ import type {
33
+ AgentTurnScope,
34
+ ChannelKey,
35
+ SessionContext,
36
+ } from "../sessions.ts";
37
+ import { type Speaker, THE_SPEAKER } from "../speakers.ts";
38
+ import { toolTurn } from "../tool-turn.ts";
33
39
  import { testDatabaseUrl } from "./database.ts";
40
+ import { callRegisteredTool, registeredTools, toolNamed } from "./run-tool.ts";
34
41
 
35
42
  export interface TestHostOptions {
36
43
  /**
@@ -74,6 +81,26 @@ export interface TestHost {
74
81
  ): Promise<{ extension: string; tools: string[] }[]>;
75
82
  /** A session context as the runtime builds it, with the real compaction wrapper. */
76
83
  sessionContext(scope?: AgentTurnScope): SessionContext;
84
+ /**
85
+ * Runs a contributed tool in the owner's session as `speaker` on `channel` (the owner's channel
86
+ * by default), as `testPlugin`'s `runTool` does, and gives the text the model would read. A tool
87
+ * that throws throws here, a refusal (`ToolRefusal`) comes back as the text the model reads, and a
88
+ * tool no plugin added throws a PluginError.
89
+ */
90
+ runTool(
91
+ name: string,
92
+ args: Record<string, unknown>,
93
+ options?: { speaker?: Speaker; channel?: ChannelKey; origin?: string },
94
+ ): Promise<string>;
95
+ /**
96
+ * What the tool's hold would show for the call, asked as a turn of `speaker` on `channel` asks
97
+ * it, or undefined when the call runs without an approval.
98
+ */
99
+ hold(
100
+ name: string,
101
+ args: Record<string, unknown>,
102
+ options?: { speaker?: Speaker; channel?: ChannelKey },
103
+ ): Promise<HoldDescription | undefined>;
77
104
  /** Stops the host, services first-started last. */
78
105
  stop(): Promise<void>;
79
106
  }
@@ -189,31 +216,6 @@ export function standInDiscord(
189
216
  };
190
217
  }
191
218
 
192
- /** Anything an extension may call or read on the Pi API besides registering a tool: accepted, does nothing. */
193
- type Ignored = (...args: readonly never[]) => Ignored;
194
-
195
- function ignored(): Ignored {
196
- return new Proxy((() => ignored()) as Ignored, {
197
- get: (target, key) => (key === "then" ? undefined : target),
198
- });
199
- }
200
-
201
- /** The tools a factory registers, by name; every other registration (handlers, commands, events) is accepted and ignored. */
202
- async function registeredBy(factory: ExtensionFactory): Promise<string[]> {
203
- const names: string[] = [];
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.
206
- const api = new Proxy(
207
- {},
208
- {
209
- get: (_target, key) =>
210
- key === "registerTool" ? registerTool : ignored(),
211
- },
212
- ) as unknown as ExtensionAPI;
213
- await factory(api);
214
- return names;
215
- }
216
-
217
219
  const noop: ExtensionFactory = () => undefined;
218
220
  /** The core's own extensions, which a session here does not load: only the plugins' registrations are listed. */
219
221
  const CORE_EXTENSIONS = {
@@ -222,6 +224,7 @@ const CORE_EXTENSIONS = {
222
224
  askUser: noop,
223
225
  selfCompactGuard: noop,
224
226
  privateMemory: noop,
227
+ surfaceFormatting: noop,
225
228
  activeTools: noop,
226
229
  };
227
230
 
@@ -296,6 +299,26 @@ export async function testHost(
296
299
  const ownerChannel = withDiscord
297
300
  ? await (given?.ownerChannel ?? (async () => "discord:owner-dm" as const))()
298
301
  : ("test:owner" as const);
302
+ let tools: string | undefined;
303
+ /** The directory the tool runs of this host keep their attachments in; made on the first run. */
304
+ const attachmentDir = () =>
305
+ (tools ??= mkdtempSync(join(tmpdir(), "roundtable-test-host-tools-")));
306
+ /** The owner's session as a tool call of `call` finds it: that speaker on that channel. */
307
+ const toolSession = (call?: {
308
+ speaker?: Speaker;
309
+ channel?: ChannelKey;
310
+ origin?: string;
311
+ }): SessionContext => {
312
+ const channel = call?.channel ?? ownerChannel;
313
+ return {
314
+ ...sessionContext(),
315
+ homeChannel: channel,
316
+ turnChannel: channel,
317
+ attachmentDir: ownerAttachmentDir(attachmentDir(), channel),
318
+ speaker: () => call?.speaker,
319
+ origin: () => call?.origin,
320
+ };
321
+ };
299
322
  /** A group seat's turns run in the group's channel; an agent's in its own. */
300
323
  const turnChannelOf = (scope?: AgentTurnScope) =>
301
324
  scope?.group ? scope.session : scope?.home;
@@ -351,13 +374,38 @@ export async function testHost(
351
374
  ))
352
375
  tools.push({
353
376
  extension: extension.name,
354
- tools: await registeredBy(extension.factory),
377
+ tools: (await registeredTools(extension.factory)).map(
378
+ (tool) => tool.name,
379
+ ),
355
380
  });
356
381
  return tools;
357
382
  },
358
383
  sessionContext,
384
+ runTool: async (name, args, call) => {
385
+ const channel = call?.channel ?? ownerChannel;
386
+ const session = toolSession(call);
387
+ const tools = [];
388
+ for (const extension of sessionExtensions(
389
+ context.sessions().plan,
390
+ session,
391
+ CORE_EXTENSIONS,
392
+ ))
393
+ tools.push(...(await registeredTools(extension.factory)));
394
+ const result = await callRegisteredTool(
395
+ toolNamed(tools, name),
396
+ args,
397
+ context.surfaces.of(channel)?.supportsFiles === true,
398
+ );
399
+ return result.text;
400
+ },
401
+ hold: async (name, args, call) =>
402
+ context
403
+ .sessions()
404
+ .holds.inTurn?.(name, args, {}, toolTurn(toolSession(call), undefined)),
359
405
  stop: async () => {
360
406
  await roundtable.shutdown("test");
407
+ if (tools) rmSync(tools, { recursive: true, force: true });
408
+ tools = undefined;
361
409
  },
362
410
  };
363
411
  }
package/src/index.ts CHANGED
@@ -155,6 +155,7 @@ export type {
155
155
  } from "./core/holds.ts";
156
156
  export type { HostEnvironment, RoundtableOptions } from "./core/host.ts";
157
157
  export { Roundtable } from "./core/host.ts";
158
+ export type { RegistrationReport } from "./core/host-registration.ts";
158
159
  export type {
159
160
  HttpRoute,
160
161
  ListenerAddress,
package/src/testing.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { mkdtempSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
4
  import type { SQL } from "bun";
6
5
  import { agentClaim } from "./core/agents/agent-claim.ts";
7
6
  import { ownerAttachmentDir } from "./core/attachments/attachment-dir.ts";
@@ -60,11 +59,6 @@ import {
60
59
  } from "./core/registry/contributions.ts";
61
60
  import { resolveProviders } from "./core/registry/providers.ts";
62
61
  import { ServiceRegistry } from "./core/registry/services.ts";
63
- import {
64
- attachReplyFile,
65
- hasReplyFileScope,
66
- withReplyFiles,
67
- } from "./core/reply-files.ts";
68
62
  import { ChannelQueue } from "./core/routing/channel-queue.ts";
69
63
  import { ChannelRouter } from "./core/routing/channel-router.ts";
70
64
  import {
@@ -83,6 +77,10 @@ import {
83
77
  import type { ChannelKey, SessionContext } from "./core/sessions.ts";
84
78
  import { type Speaker, THE_SPEAKER } from "./core/speakers.ts";
85
79
  import { mapIdentity } from "./core/testing/map-identity.ts";
80
+ import {
81
+ callRegisteredTool,
82
+ registeredTools,
83
+ } from "./core/testing/run-tool.ts";
86
84
  import { type ToolTierTable, toolTiers } from "./core/tool-tiers.ts";
87
85
 
88
86
  // Fixtures for plugin tests.
@@ -92,6 +90,7 @@ export type { TestStore } from "./core/testing/database.ts";
92
90
  export {
93
91
  describeDb,
94
92
  openTestStore,
93
+ requireDatabase,
95
94
  TEST_GUILD,
96
95
  testDatabaseUrl,
97
96
  } from "./core/testing/database.ts";
@@ -100,6 +99,8 @@ export type { TestLocale } from "./core/testing/locale.ts";
100
99
  export { useTestLocale } from "./core/testing/locale.ts";
101
100
  export { OWNER_SPEAKER } from "./core/testing/owner.ts";
102
101
  export { partial } from "./core/testing/partial.ts";
102
+ export type { UsePostgresOptions } from "./core/testing/postgres.ts";
103
+ export { usePostgresForTests } from "./core/testing/postgres.ts";
103
104
  export type {
104
105
  FakePrecheck,
105
106
  FakePrecheckAnswer,
@@ -447,6 +448,7 @@ export async function testPlugin(
447
448
  sessions: () => linked ?? unlinked("sessions"),
448
449
  toolTiers: tiers,
449
450
  prompts: (channel, speaker) => surfaces.prompts(channel, speaker),
451
+ formatting: (channel) => surfaces.of(channel)?.formatting,
450
452
  agents: {
451
453
  workDir: tmpdir(),
452
454
  skills: () => [],
@@ -606,6 +608,14 @@ export async function testPlugin(
606
608
  // The plugin's own surfaces start as services; the injected ones start beside them, after.
607
609
  const deliver = (message: InboundMessage) =>
608
610
  void conversations.handle(message);
611
+ // As the host does, the plugin's preflight runs once everything is set up and before any service starts.
612
+ try {
613
+ await plugin.preflight?.();
614
+ } catch (error) {
615
+ // The runtime the provider built is not left running when the plugin refuses.
616
+ await runtime?.dispose?.();
617
+ throw error;
618
+ }
609
619
  for (const service of registry.services) await service.start?.();
610
620
  for (const surface of options.surfaces ?? []) await surface.start(deliver);
611
621
  await runtime?.preflight?.();
@@ -627,21 +637,6 @@ export async function testPlugin(
627
637
  async runTool(name, args, runOptions) {
628
638
  const tool = registry.tools.find((item) => item.name === name);
629
639
  if (!tool) throw new PluginError(`tool ${name} is not registered`);
630
- const registered: {
631
- execute(
632
- id: string,
633
- params: Record<string, unknown>,
634
- signal?: AbortSignal,
635
- ): Promise<{
636
- content: readonly {
637
- type: string;
638
- text?: string;
639
- data?: string;
640
- mimeType?: string;
641
- }[];
642
- isError?: boolean;
643
- }>;
644
- }[] = [];
645
640
  const channel = runOptions?.channel ?? "test:1";
646
641
  const session = {
647
642
  kind: "agent",
@@ -660,44 +655,18 @@ export async function testPlugin(
660
655
  throw new Error("test session cannot run tasks");
661
656
  },
662
657
  } satisfies SessionContext;
663
- const factory = tool.session.snapshot().factory(session);
664
- // SAFETY: defineTool's session factory only calls registerTool; the fake captures its definition.
665
- factory?.({
666
- registerTool: (definition: (typeof registered)[number]) => {
667
- registered.push(definition);
668
- },
669
- } as unknown as ExtensionAPI);
670
- const execute = registered[0]?.execute;
671
- if (!execute)
658
+ const [registered] = await registeredTools(
659
+ tool.session.snapshot().factory(session),
660
+ );
661
+ if (!registered)
672
662
  throw new PluginError(`tool ${name} did not register in the session`);
673
- const result = await withReplyFiles(
663
+ const result = await callRegisteredTool(
664
+ registered,
665
+ args,
674
666
  surfaces.of(channel)?.supportsFiles === true,
675
- async () => {
676
- const output = await execute("test-call", args);
677
- for (const item of output.content)
678
- if (item.type === "image" && item.data && item.mimeType)
679
- images.push({
680
- channel,
681
- image: {
682
- mimeType: item.mimeType,
683
- data: new Uint8Array(Buffer.from(item.data, "base64")),
684
- },
685
- });
686
- return {
687
- ok: true,
688
- text: output.content
689
- .filter((item) => item.type === "text")
690
- .map((item) => item.text ?? "")
691
- .join("\n"),
692
- };
693
- },
694
667
  );
695
- if (!result.ok) throw result.error;
696
- for (const file of result.files ?? []) {
697
- files.push({ channel, file });
698
- // When runTool is used by a fake runtime, forward into the enclosing real turn.
699
- if (hasReplyFileScope()) attachReplyFile(file);
700
- }
668
+ for (const image of result.images) images.push({ channel, image });
669
+ for (const file of result.files) files.push({ channel, file });
701
670
  return result.text;
702
671
  },
703
672
  async stop() {
@@ -1,6 +1,6 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { PluginError } from "pi-roundtable";
3
- import { testPlugin } from "pi-roundtable/testing";
3
+ import { recordingLogger, testPlugin } from "pi-roundtable/testing";
4
4
  import {
5
5
  type CodexFetch,
6
6
  codexImages,
@@ -116,3 +116,31 @@ test("codex-images refuses to start without an openai-codex login", async () =>
116
116
  expect((failure as PluginError).message).toContain("openai-codex");
117
117
  expect((failure as PluginError).message).toContain("Log in");
118
118
  });
119
+
120
+ test("codex-images with onMissingLogin warn starts without a login and fails the drawing", async () => {
121
+ const plugin = createCodexImages({
122
+ onMissingLogin: "warn",
123
+ fetch: async () => stream(completed),
124
+ });
125
+ const harness = await testPlugin(plugin);
126
+ const failure = await plugin.providers?.images?.("a fox", []).then(
127
+ () => undefined,
128
+ (error: unknown) => error,
129
+ );
130
+ expect(failure).toBeInstanceOf(PluginError);
131
+ expect((failure as PluginError).message).toContain("openai-codex");
132
+ await harness.stop();
133
+ });
134
+
135
+ test("codex-images with onMissingLogin warn logs one warning at setup", async () => {
136
+ const { logger, lines } = recordingLogger();
137
+ const plugin = createCodexImages({ onMissingLogin: "warn" });
138
+ // SAFETY: setup reads only apiKey and logger.
139
+ await plugin.setup?.({ apiKey: async () => undefined, logger } as never);
140
+ expect(lines.map((line) => line.level)).toEqual(["warn"]);
141
+ expect(lines[0]?.message).toContain("openai-codex");
142
+ expect(lines[0]?.message).toContain(
143
+ "Drawing images fails until the login exists.",
144
+ );
145
+ expect(lines[0]?.message).not.toContain("plugin codex-images:");
146
+ });
@@ -31,6 +31,11 @@ export interface CodexImagesOptions {
31
31
  fetch?: CodexFetch;
32
32
  /** The model that routes the request; default `gpt-6-sol`. */
33
33
  model?: string;
34
+ /**
35
+ * What setup does when the openai-codex login is missing or unusable: "throw" (the default)
36
+ * stops the host; "warn" logs a warning, and a drawing then fails with the same PluginError.
37
+ */
38
+ onMissingLogin?: "throw" | "warn";
34
39
  }
35
40
 
36
41
  /** The ChatGPT account the login token belongs to, from the token's claims. */
@@ -197,7 +202,11 @@ const NO_LOGIN = `the host has no login for ${CODEX_PROVIDER}, the provider that
197
202
 
198
203
  /** The plugin, with its request function and model replaceable for a test. */
199
204
  export function createCodexImages(options: CodexImagesOptions = {}) {
200
- const { fetch: fetchImpl = fetch, model = CODEX_MODEL } = options;
205
+ const {
206
+ fetch: fetchImpl = fetch,
207
+ model = CODEX_MODEL,
208
+ onMissingLogin = "throw",
209
+ } = options;
201
210
  let apiKey: ((provider: string) => Promise<string | undefined>) | undefined;
202
211
  // The login refreshes its token, so each image reads it again instead of keeping the first.
203
212
  const token = async (): Promise<string> => {
@@ -214,7 +223,15 @@ export function createCodexImages(options: CodexImagesOptions = {}) {
214
223
  },
215
224
  async setup(context) {
216
225
  apiKey = context.apiKey;
217
- await token();
226
+ try {
227
+ await token();
228
+ } catch (error) {
229
+ if (onMissingLogin !== "warn" || !(error instanceof PluginError))
230
+ throw error;
231
+ context.logger.warn(
232
+ `${NO_LOGIN} Drawing images fails until the login exists.`,
233
+ );
234
+ }
218
235
  return {};
219
236
  },
220
237
  });