pi-roundtable 0.1.0 → 0.2.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.
- package/CHANGELOG.md +148 -0
- package/README.md +10 -6
- package/README.zh-TW.md +144 -0
- package/docs/plugins.md +1448 -66
- package/examples/echo-runtime.test.ts +122 -0
- package/examples/echo-runtime.ts +107 -0
- package/examples/events.test.ts +1 -0
- package/examples/events.ts +2 -2
- package/examples/fake-surface.test.ts +180 -0
- package/examples/fake-surface.ts +109 -0
- package/examples/interactions.test.ts +12 -3
- package/examples/interactions.ts +19 -19
- package/examples/shared-services.test.ts +74 -0
- package/examples/shared-services.ts +60 -0
- package/examples/study-room.test.ts +69 -0
- package/examples/study-room.ts +52 -0
- package/examples/support-desk.test.ts +34 -0
- package/examples/support-desk.ts +40 -0
- package/package.json +11 -3
- package/src/cli/checks/database.ts +11 -30
- package/src/cli/checks/images.ts +19 -0
- package/src/cli/doctor.ts +6 -0
- package/src/cli/main.ts +2 -1
- package/src/core/agents/agent-claim.ts +42 -16
- package/src/core/agents/agent-messages.ts +4 -4
- package/src/core/agents/agent-ports.ts +8 -21
- package/src/core/agents/agent-prompt.ts +3 -1
- package/src/core/agents/agent-store.ts +6 -4
- package/src/core/agents/agent-team-fixture.ts +389 -0
- package/src/core/agents/agent-team.ts +26 -25
- package/src/core/agents/agent-tools.ts +34 -15
- package/src/core/agents/avatar-studio.ts +40 -6
- package/src/core/agents/fallback-avatar.ts +85 -0
- package/src/core/agents/team-editing.ts +49 -10
- package/src/core/agents/team-keys.ts +22 -9
- package/src/core/agents/team-layout.ts +4 -3
- package/src/core/agents/team-lifecycle.ts +28 -2
- package/src/core/agents/team-options.ts +9 -5
- package/src/core/agents/team-status.ts +4 -4
- package/src/core/agents/team-text.ts +8 -6
- package/src/core/agents/team-turn-types.ts +4 -4
- package/src/core/agents/team-turns.ts +6 -5
- package/src/core/builtin/agent-server.ts +187 -121
- package/src/core/builtin/discord-admin.ts +41 -0
- package/src/core/builtin/discord.ts +65 -40
- package/src/core/builtin/modules.ts +67 -58
- package/src/core/builtin/session-tool.ts +24 -0
- package/src/core/builtin/skills.ts +72 -0
- package/src/core/builtin/stores.ts +55 -31
- package/src/core/config/config.ts +45 -4
- package/src/core/config/schema.ts +6 -0
- package/src/core/contract/channels.ts +54 -14
- package/src/core/contract/providers.ts +17 -0
- package/src/core/contract/runtime.ts +138 -0
- package/src/core/contract/services.ts +45 -0
- package/src/core/contract/surface.ts +88 -0
- package/src/core/db/migrations.ts +118 -3
- package/src/core/define-roundtable.ts +70 -46
- package/src/core/define.ts +2 -1
- package/src/core/discord/agent-commands.ts +39 -14
- package/src/core/discord/agent-panel.ts +66 -0
- package/src/core/discord/channel-executor.ts +6 -3
- package/src/core/discord/channel-operations.ts +14 -11
- package/src/core/discord/command-collection.ts +46 -0
- package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
- package/src/core/discord/connection.ts +35 -0
- package/src/core/discord/discord-surface.ts +32 -94
- package/src/core/discord/dispatch-threads.ts +2 -2
- package/src/core/discord/inbound-message.ts +99 -0
- package/src/core/discord/interaction-module.ts +57 -1
- package/src/core/discord/owner-cards.ts +4 -4
- package/src/core/discord/owner-command.ts +44 -11
- package/src/core/discord/owner-discord.ts +2 -2
- package/src/core/discord/schedule-commands.ts +21 -18
- package/src/core/discord/stop-button.ts +44 -1
- package/src/core/domain/attachment.ts +12 -8
- package/src/core/domain/conversation.ts +3 -4
- package/src/core/domain/owner-prompts.ts +5 -5
- package/src/core/domain/ports.ts +9 -53
- package/src/core/freeze.ts +9 -0
- package/src/core/host.ts +255 -82
- package/src/core/http/listeners.ts +55 -20
- package/src/core/i18n/agent-panel.ts +4 -0
- package/src/core/i18n/index.ts +12 -2
- package/src/core/i18n/owner.ts +7 -1
- package/src/core/i18n/schedules.ts +12 -6
- package/src/core/identity.ts +1 -1
- package/src/core/judging/effort-judge.ts +25 -3
- package/src/core/log.ts +21 -3
- package/src/core/models.ts +2 -2
- package/src/core/modules/background/background-turns.ts +6 -4
- package/src/core/modules/delegation/delegate.ts +3 -2
- package/src/core/modules/delegation/delegator.ts +15 -13
- package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
- package/src/core/modules/discord-admin/discord-admin.ts +6 -6
- package/src/core/modules/host-shell/shell-policy.ts +8 -3
- package/src/core/modules/memory/owner-memory-store.ts +27 -25
- package/src/core/modules/memory/owner-memory.ts +9 -13
- package/src/core/modules/schedules/recurrence.ts +5 -3
- package/src/core/modules/schedules/schedule-store.ts +11 -11
- package/src/core/modules/schedules/schedule-tools.ts +22 -20
- package/src/core/modules/schedules/scheduler.ts +7 -2
- package/src/core/modules/schedules/schedules.ts +9 -3
- package/src/core/modules/skills/skill-registry.ts +3 -2
- package/src/core/modules/skills/skill-store.ts +2 -15
- package/src/core/modules/skills/skill-tools.ts +10 -4
- package/src/core/ops/error-reporter.ts +1 -1
- package/src/core/plugin.ts +232 -28
- package/src/core/registry/contributions.ts +173 -14
- package/src/core/registry/providers.ts +35 -5
- package/src/core/registry/services.ts +229 -0
- package/src/core/routing/channel-queue.ts +5 -0
- package/src/core/routing/channel-router.ts +21 -7
- package/src/core/routing/conversation-turns.ts +139 -0
- package/src/core/routing/message-text.ts +9 -2
- package/src/core/routing/surface-port.ts +39 -0
- package/src/core/runtime/conversation-sessions.ts +4 -3
- package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
- package/src/core/runtime/mcp.ts +1 -1
- package/src/core/runtime/pending-confirmation-store.ts +10 -12
- package/src/core/runtime/pi-agent-runtime.ts +9 -6
- package/src/core/runtime/prompt-slot.ts +8 -3
- package/src/core/runtime/runtime-types.ts +9 -35
- package/src/core/runtime/session-factory.ts +21 -5
- package/src/core/runtime/text-tools.ts +1 -3
- package/src/core/services.ts +238 -90
- package/src/core/sessions.ts +3 -2
- package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
- package/src/core/shared/delegate-tool.ts +4 -3
- package/src/core/shared/schedule-tools.ts +22 -13
- package/src/core/shared/session-messages.ts +1 -1
- package/src/core/speakers.ts +6 -38
- package/src/core/testing/database.ts +1 -7
- package/src/core/testing/eager-catalog.ts +30 -0
- package/src/core/testing/locale.ts +23 -5
- package/src/core/testing/modules.ts +105 -41
- package/src/core/testing/test-host.ts +279 -0
- package/src/core/testing/tool-set.ts +64 -0
- package/src/core/time.ts +6 -1
- package/src/core/tool-set.snapshot.json +288 -0
- package/src/core/tool-tiers.ts +17 -57
- package/src/discord/index.ts +64 -0
- package/src/index.ts +181 -10
- package/src/kit/channels.ts +14 -0
- package/src/kit/domain.ts +18 -0
- package/src/kit/holds.ts +4 -0
- package/src/kit/index.ts +129 -0
- package/src/kit/judging.ts +10 -0
- package/src/kit/memory.ts +3 -0
- package/src/kit/mirror.ts +19 -0
- package/src/kit/presentation.ts +7 -0
- package/src/kit/shell.ts +7 -0
- package/src/kit/skills.ts +8 -0
- package/src/kit/support.ts +21 -0
- package/src/kit/threads.ts +7 -0
- package/src/kit/tools.ts +14 -0
- package/src/kit/worker.ts +16 -0
- package/src/testing.ts +394 -34
- package/examples/guide.test.ts +0 -52
- package/src/cli/add-plugin.test.ts +0 -107
- package/src/cli/checks/basic.test.ts +0 -276
- package/src/cli/checks/database.test.ts +0 -117
- package/src/cli/checks/discord.test.ts +0 -200
- package/src/cli/cli.test.ts +0 -122
- package/src/cli/config-edit.test.ts +0 -130
- package/src/cli/doctor.test.ts +0 -184
- package/src/cli/init.test.ts +0 -127
- package/src/cli/size.test.ts +0 -9
- package/src/cli/templates.test.ts +0 -129
- package/src/cli/testing/fixtures.ts +0 -106
- package/src/core/agents/agent-claim.test.ts +0 -142
- package/src/core/agents/agent-dashboard.test.ts +0 -120
- package/src/core/agents/agent-guild.test.ts +0 -124
- package/src/core/agents/agent-store.test.ts +0 -205
- package/src/core/agents/avatar-studio.test.ts +0 -88
- package/src/core/agents/group-round.test.ts +0 -143
- package/src/core/agents/owner-identity.test.ts +0 -172
- package/src/core/agents/team-turns.test.ts +0 -278
- package/src/core/attachments/attachments.test.ts +0 -85
- package/src/core/boundary.test.ts +0 -45
- package/src/core/builtin/modules.test.ts +0 -127
- package/src/core/config/config.test.ts +0 -135
- package/src/core/contract/discord.ts +0 -33
- package/src/core/db/migrations.test.ts +0 -237
- package/src/core/define-roundtable.test.ts +0 -134
- package/src/core/define.test.ts +0 -144
- package/src/core/discord/agent-commands.test.ts +0 -58
- package/src/core/discord/agent-discord.test.ts +0 -70
- package/src/core/discord/dispatch-thread-host.test.ts +0 -114
- package/src/core/discord/dispatch-threads.test.ts +0 -94
- package/src/core/discord/owner-cards.test.ts +0 -435
- package/src/core/discord/owner-discord.test.ts +0 -325
- package/src/core/domain/expression.ts +0 -21
- package/src/core/domain/profile.ts +0 -32
- package/src/core/drain.test.ts +0 -46
- package/src/core/events.test.ts +0 -81
- package/src/core/holds.test.ts +0 -54
- package/src/core/host.test.ts +0 -536
- package/src/core/http/listeners.test.ts +0 -164
- package/src/core/i18n/i18n.test.ts +0 -139
- package/src/core/identity.test.ts +0 -18
- package/src/core/judging/effort-judge.test.ts +0 -112
- package/src/core/judging/model-judge.test.ts +0 -126
- package/src/core/modules/delegation/delegator.test.ts +0 -170
- package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
- package/src/core/modules/schedules/schedule.test.ts +0 -358
- package/src/core/modules/skills/skill-kind.test.ts +0 -55
- package/src/core/modules/skills/skill-registry.test.ts +0 -376
- package/src/core/ops/error-reporter.test.ts +0 -283
- package/src/core/presentation/card-cadence.ts +0 -41
- package/src/core/presentation/presentation.test.ts +0 -166
- package/src/core/public-entry.test.ts +0 -59
- package/src/core/registry/contributions.test.ts +0 -215
- package/src/core/registry/interactions.test.ts +0 -87
- package/src/core/registry/providers.test.ts +0 -103
- package/src/core/routing/channel-queue.test.ts +0 -31
- package/src/core/routing/channel-router.test.ts +0 -326
- package/src/core/routing/conversation-kind.ts +0 -14
- package/src/core/routing/settle-turn.test.ts +0 -21
- package/src/core/runtime/compaction-tiers.test.ts +0 -227
- package/src/core/runtime/extensions/ask-user.test.ts +0 -114
- package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
- package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
- package/src/core/runtime/session-archive.test.ts +0 -20
- package/src/core/runtime/steerable-run.test.ts +0 -264
- package/src/core/runtime/text-tools.test.ts +0 -110
- package/src/core/runtime/turn-answer.test.ts +0 -72
- package/src/core/runtime/worker-task.test.ts +0 -82
- package/src/core/services.test.ts +0 -28
- package/src/core/sessions.test.ts +0 -85
- package/src/core/shared/unix-server.ts +0 -18
- package/src/core/size.test.ts +0 -9
- package/src/core/speakers.test.ts +0 -78
- package/src/core/testing/file-size.ts +0 -34
- package/src/core/time.test.ts +0 -166
- package/src/core/tool-tiers.test.ts +0 -117
- package/src/entries.test.ts +0 -112
- package/src/testing.test.ts +0 -156
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
definePlugin,
|
|
4
|
+
PluginError,
|
|
5
|
+
Roundtable,
|
|
6
|
+
type RoundtablePlugin,
|
|
7
|
+
} from "pi-roundtable";
|
|
8
|
+
import { OWNER_SPEAKER, silentLogger, testPlugin } from "pi-roundtable/testing";
|
|
9
|
+
import { createEchoRuntime, EchoRuntime, echoRuntime } from "./echo-runtime.ts";
|
|
10
|
+
import { FakeSurface } from "./fake-surface.ts";
|
|
11
|
+
|
|
12
|
+
/** One host runs per process, so each test stops its own. */
|
|
13
|
+
const hosts: Roundtable[] = [];
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
for (const host of hosts.splice(0)) await host.shutdown("test");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function host(plugins: RoundtablePlugin[]): Roundtable {
|
|
19
|
+
const roundtable = new Roundtable({ logger: silentLogger() }, plugins);
|
|
20
|
+
hosts.push(roundtable);
|
|
21
|
+
return roundtable;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("the harness builds the runtime from the slot, the way the agent server does", async () => {
|
|
25
|
+
const harness = await testPlugin(echoRuntime);
|
|
26
|
+
expect(harness.runtime).toBeInstanceOf(EchoRuntime);
|
|
27
|
+
await harness.stop();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a turn run through context.turns goes through the plugin's runtime, and the answer goes out through the surface", async () => {
|
|
31
|
+
const surface = new FakeSurface();
|
|
32
|
+
const harness = await testPlugin(echoRuntime, { surfaces: [surface] });
|
|
33
|
+
const result = await harness.turns.run({
|
|
34
|
+
channel: "fake:room",
|
|
35
|
+
kind: "owner",
|
|
36
|
+
text: "hello",
|
|
37
|
+
speaker: OWNER_SPEAKER,
|
|
38
|
+
});
|
|
39
|
+
expect(result).toEqual({ ok: true, text: "[owner] hello" });
|
|
40
|
+
expect(surface.replies).toEqual([
|
|
41
|
+
{ channel: "fake:room", reply: { chunks: ["[owner] hello"] } },
|
|
42
|
+
]);
|
|
43
|
+
// The surface showed typing and the stop control while the turn ran.
|
|
44
|
+
expect(surface.typing).toEqual(["start fake:room", "stop fake:room"]);
|
|
45
|
+
expect(surface.stops).toEqual(["show fake:room", "hide fake:room"]);
|
|
46
|
+
expect(harness.events.map(({ name }) => name)).toEqual([
|
|
47
|
+
"turnStarted",
|
|
48
|
+
"turnEnded",
|
|
49
|
+
]);
|
|
50
|
+
await harness.stop();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("an agent's turn carries its scope, and its conversation is the scope's session", async () => {
|
|
54
|
+
const harness = await testPlugin(echoRuntime);
|
|
55
|
+
const { runtime } = harness;
|
|
56
|
+
if (!runtime) throw new Error("the plugin fills the runtime slot");
|
|
57
|
+
const result = await runtime.runTurn({
|
|
58
|
+
channel: "fake:agent-room",
|
|
59
|
+
selection: { id: "agent", tools: [], groups: [] },
|
|
60
|
+
text: "status?",
|
|
61
|
+
agent: {
|
|
62
|
+
name: "infra",
|
|
63
|
+
session: "fake:agent-room",
|
|
64
|
+
home: "fake:agent-room",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
expect(result).toEqual({ ok: true, text: "[agent infra] status?" });
|
|
68
|
+
expect(await runtime.recentTranscript("fake:agent-room", 10)).toEqual([
|
|
69
|
+
{ role: "user", text: "status?" },
|
|
70
|
+
{ role: "assistant", text: "[agent infra] status?" },
|
|
71
|
+
]);
|
|
72
|
+
await runtime.startFresh("fake:agent-room");
|
|
73
|
+
expect(await runtime.recentTranscript("fake:agent-room", 10)).toEqual([]);
|
|
74
|
+
await harness.stop();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a turn of a kind nobody wrote a persona for fails with the fix, and the surface says so", async () => {
|
|
78
|
+
const surface = new FakeSurface();
|
|
79
|
+
const harness = await testPlugin(echoRuntime, { surfaces: [surface] });
|
|
80
|
+
const result = await harness.turns.run({
|
|
81
|
+
channel: "fake:room",
|
|
82
|
+
kind: "quiz",
|
|
83
|
+
text: "hello",
|
|
84
|
+
speaker: OWNER_SPEAKER,
|
|
85
|
+
});
|
|
86
|
+
expect(result.ok).toBe(false);
|
|
87
|
+
expect(!result.ok && result.error.message).toContain("`personas`");
|
|
88
|
+
expect(surface.replies).toHaveLength(1);
|
|
89
|
+
await harness.stop();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the host resolves the slot to the plugin's factory", async () => {
|
|
93
|
+
let filled = false;
|
|
94
|
+
const probe = definePlugin({
|
|
95
|
+
name: "probe",
|
|
96
|
+
setup: ({ providers }) => {
|
|
97
|
+
filled = providers.filled.has("runtime");
|
|
98
|
+
expect(providers.runtime).toBe(createEchoRuntime);
|
|
99
|
+
return { services: [{ name: "probe" }] };
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
await host([echoRuntime, probe]).run();
|
|
103
|
+
expect(filled).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("only one plugin may fill the runtime slot", async () => {
|
|
107
|
+
const another = definePlugin({
|
|
108
|
+
name: "another-runtime",
|
|
109
|
+
providers: { runtime: createEchoRuntime },
|
|
110
|
+
setup: () => ({}),
|
|
111
|
+
});
|
|
112
|
+
const error = await host([echoRuntime, another])
|
|
113
|
+
.run()
|
|
114
|
+
.then(
|
|
115
|
+
() => undefined,
|
|
116
|
+
(failure: unknown) => failure,
|
|
117
|
+
);
|
|
118
|
+
expect(error).toBeInstanceOf(PluginError);
|
|
119
|
+
expect(String(error)).toContain(
|
|
120
|
+
"provider slot runtime is already filled by plugin echo-runtime",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AgentRunError,
|
|
3
|
+
type AgentRuntime,
|
|
4
|
+
type ChannelKey,
|
|
5
|
+
definePlugin,
|
|
6
|
+
type PendingConfirmation,
|
|
7
|
+
type RuntimeDeps,
|
|
8
|
+
type RuntimeFactory,
|
|
9
|
+
type TranscriptEntry,
|
|
10
|
+
type TurnRequest,
|
|
11
|
+
type TurnResult,
|
|
12
|
+
} from "pi-roundtable";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A runtime runs the conversations of the agent server and of every claim that calls
|
|
16
|
+
* `context.turns.run`: one conversation per channel key, its history, and its held actions.
|
|
17
|
+
* This one answers with the text it was given, and keeps each conversation's transcript in memory.
|
|
18
|
+
*/
|
|
19
|
+
export class EchoRuntime implements AgentRuntime {
|
|
20
|
+
readonly #deps: RuntimeDeps;
|
|
21
|
+
readonly #transcripts = new Map<ChannelKey, TranscriptEntry[]>();
|
|
22
|
+
|
|
23
|
+
constructor(deps: RuntimeDeps) {
|
|
24
|
+
this.#deps = deps;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async runTurn(request: TurnRequest): Promise<TurnResult> {
|
|
28
|
+
// An agent's turn carries the agent's scope, and its conversation is the scope's session.
|
|
29
|
+
const conversation = request.agent?.session ?? request.channel;
|
|
30
|
+
const prompt = this.#promptOf(request);
|
|
31
|
+
if (prompt === undefined)
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: new AgentRunError(
|
|
35
|
+
`no persona for the conversation kind "${request.kind}": a plugin adds one with \`personas\``,
|
|
36
|
+
),
|
|
37
|
+
};
|
|
38
|
+
const text = `[${prompt}] ${request.text}`;
|
|
39
|
+
const transcript = this.#transcripts.get(conversation) ?? [];
|
|
40
|
+
transcript.push(
|
|
41
|
+
{ role: "user", text: request.text },
|
|
42
|
+
{ role: "assistant", text },
|
|
43
|
+
);
|
|
44
|
+
this.#transcripts.set(conversation, transcript);
|
|
45
|
+
return { ok: true, text };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a model would get as its system prompt: the agent's name, or the persona of the kind. */
|
|
49
|
+
#promptOf(request: TurnRequest): string | undefined {
|
|
50
|
+
if (request.agent) return `agent ${request.agent.name}`;
|
|
51
|
+
// Only the turn's kind picks the persona; "owner" is the kind of a turn that names none.
|
|
52
|
+
const kind = request.kind ?? "owner";
|
|
53
|
+
return kind === "owner"
|
|
54
|
+
? (this.#deps.sessions().persona("owner") ?? "owner")
|
|
55
|
+
: this.#deps.sessions().persona(kind);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Nothing runs long enough to take a steering message, so each waits for its own turn. */
|
|
59
|
+
async steer(): Promise<boolean> {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** No turn outlives its `runTurn`, so there is never one to stop. */
|
|
64
|
+
stop(): boolean {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async startFresh(conversation: ChannelKey): Promise<void> {
|
|
69
|
+
this.#transcripts.delete(conversation);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async deleteConversation(conversation: ChannelKey): Promise<void> {
|
|
73
|
+
this.#transcripts.delete(conversation);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** This runtime holds no actions for approval; a real one keeps them in `deps.confirmations`. */
|
|
77
|
+
pendingConfirmation(): PendingConfirmation | undefined {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async heldActions(): Promise<PendingConfirmation | undefined> {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async recentTranscript(
|
|
86
|
+
conversation: ChannelKey,
|
|
87
|
+
limit: number,
|
|
88
|
+
): Promise<TranscriptEntry[]> {
|
|
89
|
+
return (this.#transcripts.get(conversation) ?? []).slice(-limit);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The factory the agent server calls once, with what a runtime needs from the host. */
|
|
94
|
+
export const createEchoRuntime: RuntimeFactory = (deps) => {
|
|
95
|
+
deps.logger.info("the echo runtime replaces Pi");
|
|
96
|
+
return new EchoRuntime(deps);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A plugin fills the `runtime` slot to replace the whole conversation runtime; without one the
|
|
101
|
+
* agent server builds the Pi runtime. One plugin may fill it.
|
|
102
|
+
*/
|
|
103
|
+
export const echoRuntime = definePlugin({
|
|
104
|
+
name: "echo-runtime",
|
|
105
|
+
providers: { runtime: createEchoRuntime },
|
|
106
|
+
setup: () => ({}),
|
|
107
|
+
});
|
package/examples/events.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ test("the handlers record a turn, a team change, and the shutdown", async () =>
|
|
|
9
9
|
// The harness does not deliver the core's events: call the handlers with the payload the core sends.
|
|
10
10
|
const turn = {
|
|
11
11
|
agent: "guide",
|
|
12
|
+
kind: "agent",
|
|
12
13
|
channel: "discord:1",
|
|
13
14
|
speaker: undefined,
|
|
14
15
|
} as const;
|
package/examples/events.ts
CHANGED
|
@@ -7,10 +7,10 @@ export function turnLog(lines: string[]) {
|
|
|
7
7
|
setup: () => ({
|
|
8
8
|
events: {
|
|
9
9
|
turnStarted: (turn) => {
|
|
10
|
-
lines.push(`${turn.agent} started`);
|
|
10
|
+
lines.push(`${turn.agent ?? turn.kind} started`);
|
|
11
11
|
},
|
|
12
12
|
turnEnded: (turn) => {
|
|
13
|
-
lines.push(`${turn.agent} ${turn.result}`);
|
|
13
|
+
lines.push(`${turn.agent ?? turn.kind} ${turn.result}`);
|
|
14
14
|
},
|
|
15
15
|
changed: () => {
|
|
16
16
|
lines.push("team changed");
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type ChatSurface,
|
|
4
|
+
definePlugin,
|
|
5
|
+
NotLinkedError,
|
|
6
|
+
PluginError,
|
|
7
|
+
Roundtable,
|
|
8
|
+
type RoundtablePlugin,
|
|
9
|
+
type SurfacePort,
|
|
10
|
+
} from "pi-roundtable";
|
|
11
|
+
import { silentLogger } from "pi-roundtable/testing";
|
|
12
|
+
import { FakeSurface, fakeChat } from "./fake-surface.ts";
|
|
13
|
+
|
|
14
|
+
/** One host runs per process, so each test stops its own. */
|
|
15
|
+
const hosts: Roundtable[] = [];
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
for (const host of hosts.splice(0)) await host.shutdown("test");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function host(
|
|
21
|
+
plugins: RoundtablePlugin[],
|
|
22
|
+
options: Partial<ConstructorParameters<typeof Roundtable>[0]> = {},
|
|
23
|
+
): Roundtable {
|
|
24
|
+
const roundtable = new Roundtable(
|
|
25
|
+
{ logger: silentLogger(), ...options },
|
|
26
|
+
plugins,
|
|
27
|
+
);
|
|
28
|
+
hosts.push(roundtable);
|
|
29
|
+
return roundtable;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function until(done: () => boolean): Promise<void> {
|
|
33
|
+
for (let waited = 0; !done() && waited < 1000; waited += 5)
|
|
34
|
+
await Bun.sleep(5);
|
|
35
|
+
expect(done()).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The surface port the host gave a plugin, for a test to call once the host is running. */
|
|
39
|
+
function portOf(): { plugin: RoundtablePlugin; port: () => SurfacePort } {
|
|
40
|
+
let port: SurfacePort | undefined;
|
|
41
|
+
return {
|
|
42
|
+
plugin: definePlugin({
|
|
43
|
+
name: "port-probe",
|
|
44
|
+
setup: ({ surfaces }) => {
|
|
45
|
+
port = surfaces;
|
|
46
|
+
return { services: [{ name: "probe" }] };
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
port: () => {
|
|
50
|
+
if (!port) throw new Error("the plugin was not set up");
|
|
51
|
+
return port;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("a message of the surface reaches the claim on its keys, and the reply goes out through the surface", async () => {
|
|
57
|
+
const surface = new FakeSurface();
|
|
58
|
+
await host([fakeChat(surface)]).run();
|
|
59
|
+
surface.say("fake:room", "hello");
|
|
60
|
+
await until(() => surface.replies.length > 0);
|
|
61
|
+
expect(surface.replies).toEqual([
|
|
62
|
+
{ channel: "fake:room", reply: { chunks: ["echo: hello"] } },
|
|
63
|
+
]);
|
|
64
|
+
expect(surface.typing).toEqual(["start fake:room", "stop fake:room"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a message whose channel belongs to another surface is dropped, not handed to the claim", async () => {
|
|
68
|
+
const surface = new FakeSurface();
|
|
69
|
+
await host([fakeChat(surface)]).run();
|
|
70
|
+
surface.say("discord:room", "hello");
|
|
71
|
+
await Bun.sleep(30);
|
|
72
|
+
expect(surface.replies).toEqual([]);
|
|
73
|
+
surface.say("fake:room", "again");
|
|
74
|
+
await until(() => surface.replies.length > 0);
|
|
75
|
+
expect(surface.replies.map(({ channel }) => channel)).toEqual(["fake:room"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the port picks the surface by the key's prefix and refuses a prefix nobody serves", async () => {
|
|
79
|
+
const surface = new FakeSurface();
|
|
80
|
+
const probe = portOf();
|
|
81
|
+
await host([fakeChat(surface), probe.plugin]).run();
|
|
82
|
+
const surfaces = probe.port();
|
|
83
|
+
expect(surfaces.of("fake:1")).toBe(surface);
|
|
84
|
+
expect(surfaces.of("mcp:1")).toBeUndefined();
|
|
85
|
+
await surfaces.sendReply("fake:1", { chunks: ["hi"] });
|
|
86
|
+
expect(surface.replies).toHaveLength(1);
|
|
87
|
+
const refused = await surfaces.sendReply("mcp:1", { chunks: ["hi"] }).then(
|
|
88
|
+
() => undefined,
|
|
89
|
+
(error: unknown) => error,
|
|
90
|
+
);
|
|
91
|
+
expect(refused).toBeInstanceOf(PluginError);
|
|
92
|
+
expect(String(refused)).toContain("mcp");
|
|
93
|
+
// What a surface does not show is a no-op, and a prefix nobody serves has nothing to show.
|
|
94
|
+
surfaces.showStop("fake:1")();
|
|
95
|
+
surfaces.showStop("mcp:1")();
|
|
96
|
+
surfaces.startTyping("mcp:1")();
|
|
97
|
+
expect(surface.stops).toEqual(["show fake:1", "hide fake:1"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the owner's approvals in a channel of the surface use the surface's prompts, and other channels have none", async () => {
|
|
101
|
+
const surface = new FakeSurface();
|
|
102
|
+
const probe = portOf();
|
|
103
|
+
await host([fakeChat(surface), probe.plugin]).run();
|
|
104
|
+
const prompts = probe.port().prompts("fake:1");
|
|
105
|
+
expect(await prompts?.confirm("Run rm?", "deletes the notes")).toBe(
|
|
106
|
+
"approved",
|
|
107
|
+
);
|
|
108
|
+
expect(surface.asked).toEqual(["fake:1: Run rm?"]);
|
|
109
|
+
expect(probe.port().prompts("mcp:1")).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("two surfaces with one prefix are refused, naming both plugins", async () => {
|
|
113
|
+
const second = definePlugin({
|
|
114
|
+
name: "second-chat",
|
|
115
|
+
setup: () => ({ surfaces: [new FakeSurface()] }),
|
|
116
|
+
});
|
|
117
|
+
const error = await host([fakeChat(new FakeSurface()), second])
|
|
118
|
+
.run()
|
|
119
|
+
.then(
|
|
120
|
+
() => undefined,
|
|
121
|
+
(failure: unknown) => failure,
|
|
122
|
+
);
|
|
123
|
+
expect(error).toBeInstanceOf(PluginError);
|
|
124
|
+
expect(String(error)).toContain(
|
|
125
|
+
"surface fake is already registered by plugin fake-chat",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a surface is started before the plugin's own services and stopped after them", async () => {
|
|
130
|
+
const log: string[] = [];
|
|
131
|
+
const surface: ChatSurface = {
|
|
132
|
+
surface: "log",
|
|
133
|
+
start: async () => void log.push("start surface"),
|
|
134
|
+
stop: async () => void log.push("stop surface"),
|
|
135
|
+
sendReply: async () => undefined,
|
|
136
|
+
};
|
|
137
|
+
const plugin = definePlugin({
|
|
138
|
+
name: "log-chat",
|
|
139
|
+
setup: () => ({
|
|
140
|
+
services: [
|
|
141
|
+
{
|
|
142
|
+
name: "after",
|
|
143
|
+
start: () => void log.push("start service"),
|
|
144
|
+
stop: () => void log.push("stop service"),
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
surfaces: [surface],
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
const roundtable = host([plugin]);
|
|
151
|
+
await roundtable.run();
|
|
152
|
+
await roundtable.shutdown("test");
|
|
153
|
+
expect(log).toEqual([
|
|
154
|
+
"start surface",
|
|
155
|
+
"start service",
|
|
156
|
+
"stop service",
|
|
157
|
+
"stop surface",
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("surfaces are not available during setup", async () => {
|
|
162
|
+
const early = definePlugin({
|
|
163
|
+
name: "early",
|
|
164
|
+
setup: ({ surfaces }) => {
|
|
165
|
+
surfaces.of("fake:1");
|
|
166
|
+
return {};
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const error = await host([early])
|
|
170
|
+
.run()
|
|
171
|
+
.then(
|
|
172
|
+
() => undefined,
|
|
173
|
+
(failure: unknown) => failure,
|
|
174
|
+
);
|
|
175
|
+
expect(error).toBeInstanceOf(PluginError);
|
|
176
|
+
expect(String(error)).toContain(
|
|
177
|
+
"chat surfaces are linked once every plugin is set up",
|
|
178
|
+
);
|
|
179
|
+
expect((error as PluginError).cause).toBeInstanceOf(NotLinkedError);
|
|
180
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChannelKey,
|
|
3
|
+
type ChatSurface,
|
|
4
|
+
definePlugin,
|
|
5
|
+
type InboundMessage,
|
|
6
|
+
type OutboundReply,
|
|
7
|
+
type OwnerPrompts,
|
|
8
|
+
parseChannelKey,
|
|
9
|
+
} from "pi-roundtable";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A chat surface connects the host to one chat network. This one is an in-memory chat: its
|
|
13
|
+
* channels are the keys that start with `fake:`, and it records what the host asks of it. A real
|
|
14
|
+
* surface talks to its network in `start` and `sendReply`, and skips the optional methods it
|
|
15
|
+
* cannot do.
|
|
16
|
+
*/
|
|
17
|
+
export class FakeSurface implements ChatSurface {
|
|
18
|
+
readonly surface = "fake";
|
|
19
|
+
readonly replies: { channel: ChannelKey; reply: OutboundReply }[] = [];
|
|
20
|
+
readonly typing: string[] = [];
|
|
21
|
+
readonly stops: string[] = [];
|
|
22
|
+
readonly asked: string[] = [];
|
|
23
|
+
#deliver: ((message: InboundMessage) => void) | undefined;
|
|
24
|
+
|
|
25
|
+
/** The host hands over its router; every message the network reports goes to it. */
|
|
26
|
+
async start(deliver: (message: InboundMessage) => void): Promise<void> {
|
|
27
|
+
this.#deliver = deliver;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async stop(): Promise<void> {
|
|
31
|
+
this.#deliver = undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Someone writes in a channel. */
|
|
35
|
+
say(channel: ChannelKey, text: string): void {
|
|
36
|
+
this.#deliver?.({
|
|
37
|
+
channel,
|
|
38
|
+
messageId: `m${this.replies.length}`,
|
|
39
|
+
authorId: "1",
|
|
40
|
+
authorName: "Ada",
|
|
41
|
+
authorIsBot: false,
|
|
42
|
+
isDirect: true,
|
|
43
|
+
mentionsBot: false,
|
|
44
|
+
repliesToBot: false,
|
|
45
|
+
text,
|
|
46
|
+
attachments: [],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void> {
|
|
51
|
+
this.replies.push({ channel, reply });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
startTyping(channel: ChannelKey): () => void {
|
|
55
|
+
this.typing.push(`start ${channel}`);
|
|
56
|
+
return () => void this.typing.push(`stop ${channel}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
showStop(channel: ChannelKey): () => void {
|
|
60
|
+
this.stops.push(`show ${channel}`);
|
|
61
|
+
return () => void this.stops.push(`hide ${channel}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** How the owner would approve a held action or answer a question in a turn. */
|
|
65
|
+
prompts(channel: ChannelKey): OwnerPrompts {
|
|
66
|
+
return {
|
|
67
|
+
confirm: async (title) => {
|
|
68
|
+
this.asked.push(`${channel}: ${title}`);
|
|
69
|
+
return "approved";
|
|
70
|
+
},
|
|
71
|
+
ask: async () => undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The plugin contributes the surface, and a claim that owns the channels of its prefix and
|
|
78
|
+
* answers through `context.surfaces`, which picks the surface by the prefix of the channel's key.
|
|
79
|
+
*/
|
|
80
|
+
export function fakeChat(surface: FakeSurface) {
|
|
81
|
+
return definePlugin({
|
|
82
|
+
name: "fake-chat",
|
|
83
|
+
setup: ({ surfaces }) => ({
|
|
84
|
+
surfaces: [surface],
|
|
85
|
+
channels: [
|
|
86
|
+
{
|
|
87
|
+
name: "fake-channels",
|
|
88
|
+
priority: 10,
|
|
89
|
+
owns: (channel) => parseChannelKey(channel).surface === "fake",
|
|
90
|
+
admit: (message) => ({
|
|
91
|
+
kind: "turn",
|
|
92
|
+
run: async () => {
|
|
93
|
+
const stopTyping = surfaces.startTyping(message.channel);
|
|
94
|
+
try {
|
|
95
|
+
await surfaces.sendReply(message.channel, {
|
|
96
|
+
chunks: [`echo: ${message.text}`],
|
|
97
|
+
});
|
|
98
|
+
} finally {
|
|
99
|
+
stopTyping();
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
failure: "a fake chat turn failed",
|
|
103
|
+
}),
|
|
104
|
+
startFresh: async () => "The fake chat has nothing to start over.",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { testPlugin } from "pi-roundtable/testing";
|
|
2
|
+
import { fakeDiscord, testPlugin } from "pi-roundtable/testing";
|
|
3
3
|
import { ping } from "./interactions.ts";
|
|
4
4
|
|
|
5
5
|
/** The parts of a Discord interaction the module reads. */
|
|
@@ -14,8 +14,9 @@ function fake(subcommand: string, replies: string[]) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
test("the module answers /roundtable ping and ignores other subcommands", async () => {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
17
|
+
const discord = fakeDiscord();
|
|
18
|
+
const harness = await testPlugin(ping, { services: [discord.service] });
|
|
19
|
+
const [added] = discord.added();
|
|
19
20
|
expect(added?.rootOptions?.map((option) => option.name)).toEqual(["ping"]);
|
|
20
21
|
const replies: string[] = [];
|
|
21
22
|
expect(await added?.module.handle(fake("ping", replies))).toBe(true);
|
|
@@ -23,3 +24,11 @@ test("the module answers /roundtable ping and ignores other subcommands", async
|
|
|
23
24
|
expect(replies).toEqual(["pong"]);
|
|
24
25
|
await harness.stop();
|
|
25
26
|
});
|
|
27
|
+
|
|
28
|
+
test("the tree Discord would register has the subcommand under the root", async () => {
|
|
29
|
+
const discord = fakeDiscord();
|
|
30
|
+
await (await testPlugin(ping, { services: [discord.service] })).stop();
|
|
31
|
+
const { commands } = discord.compose();
|
|
32
|
+
expect(commands.map((command) => command.name)).toEqual(["roundtable"]);
|
|
33
|
+
expect(commands[0]?.options?.map((option) => option.name)).toEqual(["ping"]);
|
|
34
|
+
});
|
package/examples/interactions.ts
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
import { definePlugin } from "pi-roundtable";
|
|
2
|
+
import { DISCORD } from "pi-roundtable/discord";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* Slash commands belong to the Discord plugin: a plugin adds its own from setup with
|
|
6
|
+
* `commands.add`. A subcommand goes under the one root command (`/roundtable` by default); the
|
|
7
|
+
* module answers the interactions Discord sends and returns true for the ones it handled.
|
|
6
8
|
*/
|
|
7
9
|
export const ping = definePlugin({
|
|
8
10
|
name: "ping",
|
|
9
|
-
setup: () =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
await interaction.reply("pong");
|
|
22
|
-
return true;
|
|
23
|
-
},
|
|
11
|
+
setup: ({ services }) => {
|
|
12
|
+
services.get(DISCORD).commands.add({
|
|
13
|
+
rootOptions: [
|
|
14
|
+
{ type: 1, name: "ping", description: "Check that the bot answers" },
|
|
15
|
+
],
|
|
16
|
+
module: {
|
|
17
|
+
commands: () => [],
|
|
18
|
+
handle: async (interaction) => {
|
|
19
|
+
if (!interaction.isChatInputCommand()) return false;
|
|
20
|
+
if (interaction.options.getSubcommand(false) !== "ping") return false;
|
|
21
|
+
await interaction.reply("pong");
|
|
22
|
+
return true;
|
|
24
23
|
},
|
|
25
24
|
},
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
});
|
|
26
|
+
return {};
|
|
27
|
+
},
|
|
28
28
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { definePlugin, PluginError, Roundtable } from "pi-roundtable";
|
|
3
|
+
import { servicePair, silentLogger, testPlugin } from "pi-roundtable/testing";
|
|
4
|
+
import {
|
|
5
|
+
NOTE_INDEX,
|
|
6
|
+
type NoteIndex,
|
|
7
|
+
noteReader,
|
|
8
|
+
notes,
|
|
9
|
+
shoutingNotes,
|
|
10
|
+
} from "./shared-services.ts";
|
|
11
|
+
|
|
12
|
+
/** Runs a host over the plugins, and stops it again. */
|
|
13
|
+
async function withHost(
|
|
14
|
+
plugins: ConstructorParameters<typeof Roundtable>[1],
|
|
15
|
+
inside: () => void,
|
|
16
|
+
) {
|
|
17
|
+
const roundtable = new Roundtable({ logger: silentLogger() }, plugins);
|
|
18
|
+
await roundtable.run();
|
|
19
|
+
try {
|
|
20
|
+
inside();
|
|
21
|
+
} finally {
|
|
22
|
+
await roundtable.shutdown("test");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A plugin that writes a note, as any plugin after the notes may. */
|
|
27
|
+
const writer = definePlugin({
|
|
28
|
+
name: "writer",
|
|
29
|
+
setup: ({ services }) => {
|
|
30
|
+
services.get(NOTE_INDEX).add("hi");
|
|
31
|
+
return { services: [{ name: "writer" }] };
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a plugin reads what the plugin before it provided", async () => {
|
|
36
|
+
const seen: (readonly string[] | undefined)[] = [];
|
|
37
|
+
await withHost(
|
|
38
|
+
[notes(), writer, noteReader((list) => seen.push(list))],
|
|
39
|
+
() => {
|
|
40
|
+
expect(seen).toEqual([["hi"]]);
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a plugin under test gets the service it reads from the harness, and find is undefined without it", async () => {
|
|
46
|
+
const seen: (readonly string[] | undefined)[] = [];
|
|
47
|
+
const given: NoteIndex = { add: () => undefined, all: () => ["a", "b"] };
|
|
48
|
+
const harness = await testPlugin(
|
|
49
|
+
noteReader((list) => seen.push(list)),
|
|
50
|
+
{ services: [servicePair(NOTE_INDEX, given)] },
|
|
51
|
+
);
|
|
52
|
+
const bare = await testPlugin(noteReader((list) => seen.push(list)));
|
|
53
|
+
await harness.stop();
|
|
54
|
+
await bare.stop();
|
|
55
|
+
expect(seen).toEqual([["a", "b"], undefined]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a replacement takes the place of the plugin that provided the key, and everyone after it reads the replacement", async () => {
|
|
59
|
+
const seen: (readonly string[] | undefined)[] = [];
|
|
60
|
+
await withHost(
|
|
61
|
+
[notes(), shoutingNotes(), writer, noteReader((list) => seen.push(list))],
|
|
62
|
+
() => expect(seen).toEqual([["HI"]]),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a provider that does not provide what it lists is refused after setup", async () => {
|
|
67
|
+
await expect(
|
|
68
|
+
testPlugin({
|
|
69
|
+
name: "forgetful",
|
|
70
|
+
provides: [NOTE_INDEX],
|
|
71
|
+
setup: () => ({ services: [{ name: "idle" }] }),
|
|
72
|
+
}),
|
|
73
|
+
).rejects.toBeInstanceOf(PluginError);
|
|
74
|
+
});
|