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