pi-roundtable 0.9.12 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/docs/migration-0.9.md +3 -1
  3. package/docs/plugins.md +146 -21
  4. package/examples/fake-surface.test.ts +137 -0
  5. package/examples/fake-surface.ts +71 -14
  6. package/package.json +1 -1
  7. package/src/cli/checks/identity.ts +73 -5
  8. package/src/core/agents/agent-claim.ts +12 -0
  9. package/src/core/agents/team-turns.ts +11 -4
  10. package/src/core/builtin/agent-server.ts +6 -0
  11. package/src/core/config/access.ts +23 -3
  12. package/src/core/config/adapters.ts +11 -0
  13. package/src/core/config/schema.ts +14 -0
  14. package/src/core/define-roundtable.ts +1 -0
  15. package/src/core/define.ts +44 -6
  16. package/src/core/discord/card-audience.ts +104 -7
  17. package/src/core/discord/discord-owners.ts +19 -13
  18. package/src/core/discord/owner-card-types.ts +73 -0
  19. package/src/core/discord/owner-cards.ts +56 -77
  20. package/src/core/domain/conversation.ts +7 -0
  21. package/src/core/domain/ports.ts +4 -1
  22. package/src/core/holds.ts +60 -0
  23. package/src/core/i18n/cards.ts +3 -0
  24. package/src/core/identity/access-policy.ts +65 -2
  25. package/src/core/identity/caching-principal-store.ts +2 -2
  26. package/src/core/identity/identity-links.ts +96 -0
  27. package/src/core/identity/identity-plugin.ts +10 -3
  28. package/src/core/identity/identity-schema.ts +3 -1
  29. package/src/core/identity/identity-service.ts +5 -3
  30. package/src/core/identity/principal-store.ts +59 -142
  31. package/src/core/identity/principal-tables.ts +10 -2
  32. package/src/core/identity/principal-types.ts +146 -0
  33. package/src/core/interactions/prompts.ts +47 -3
  34. package/src/core/modules/schedules/precheck-tools.ts +115 -20
  35. package/src/core/routing/conversation-turns.ts +4 -1
  36. package/src/core/runtime/conversation-sessions.ts +8 -0
  37. package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
  38. package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
  39. package/src/core/runtime/extensions/gate-refusals.ts +133 -0
  40. package/src/core/runtime/extensions/held-calls.ts +91 -0
  41. package/src/core/runtime/extensions/hold-lookups.ts +108 -0
  42. package/src/core/runtime/pi-agent-runtime.ts +7 -3
  43. package/src/core/runtime/prompt-slot.ts +10 -2
  44. package/src/core/runtime/session-factory.ts +1 -0
  45. package/src/core/services.ts +11 -0
  46. package/src/core/sessions.ts +7 -0
  47. package/src/core/testing/surface-contract-support.ts +194 -0
  48. package/src/core/testing/surface-contract.ts +54 -120
  49. package/src/core/tool-turn.ts +5 -1
  50. package/src/index.ts +7 -0
@@ -0,0 +1,133 @@
1
+ import type {
2
+ HeldCall,
3
+ PendingConfirmation,
4
+ } from "../../domain/conversation.ts";
5
+ import { type OwnerIdentity, ownerWords } from "../../identity.ts";
6
+ import type { ApprovedBy } from "../../interactions/prompts.ts";
7
+ import type { Speaker } from "../../speakers.ts";
8
+ import { mayApproveNamed } from "./held-calls.ts";
9
+
10
+ /**
11
+ * The speaker of a turn as the gate records them; `name` is what an approval by their message is
12
+ * attributed to, and `tier` tells whether they approve as an owner.
13
+ */
14
+ export type SpeakerRef = Pick<Speaker, "id" | "principalId"> & {
15
+ name?: string;
16
+ tier?: Speaker["tier"];
17
+ };
18
+
19
+ /** A call an approval released, and who gave it, when that is known. */
20
+ export interface Release {
21
+ call: HeldCall;
22
+ by?: ApprovedBy;
23
+ }
24
+
25
+ /** What a call needs of the gate: nothing, a hold, or a release that spends an approval. */
26
+ export type Needing = { held: HeldCall } | { released: ApprovedBy | undefined };
27
+
28
+ /** The text for a held call that the speaker's own message confirms. */
29
+ export function askSpeaker(addressee: OwnerIdentity, call: HeldCall): string {
30
+ const o = ownerWords(addressee);
31
+ return `Held for ${o.name}'s confirmation: this would ${call.action}. Do not retry it in this turn. Tell ${o.him} exactly what will happen and ask ${o.him} to confirm; once ${o.he} agrees in ${o.his} next message, make the same call with the same input and it runs.`;
32
+ }
33
+
34
+ /**
35
+ * For a call that names approvers the speaker is neither among nor an owner of: the text that
36
+ * tells the model the truth about it, from the names of those who may approve; undefined when
37
+ * the speaker may.
38
+ */
39
+ export function unlessNamed(
40
+ call: HeldCall,
41
+ speaker: SpeakerRef | undefined,
42
+ addressee: OwnerIdentity,
43
+ isPrivate: boolean,
44
+ ): ((names: string[], missing: boolean) => string) | undefined {
45
+ if (!call.approverIds || mayApproveNamed(speaker, call)) return undefined;
46
+ const o = ownerWords(addressee);
47
+ const anyone = (call.approverIds?.length ?? 0) > 0;
48
+ return (names, missing) => {
49
+ // Names that could not be read still leave the people the tool named.
50
+ const named =
51
+ names.length === 0 && anyone
52
+ ? ["the people named for it"]
53
+ : missing
54
+ ? [...names, "others named for it"]
55
+ : names;
56
+ const who = orList([...named, "an owner"]);
57
+ const approval = `Only ${who} can approve it.`;
58
+ if (isPrivate)
59
+ return `Held, and ${o.name} cannot approve it here: this would ${call.action}. ${approval} A private conversation can only be answered by its person, so it stays held and nothing ${o.he} says here releases it. Do not retry it, and do not ask ${o.him} to confirm it or to say yes. Tell ${o.him} plainly that it was not done and needs ${who}.`;
60
+ return `Held, and ${o.name} cannot approve it: this would ${call.action}. ${approval} Do not retry it in this turn, and do not ask ${o.name} to confirm it. Tell ${o.him} plainly that it was not done, and ask ${who} to confirm in this channel; once one of them agrees in a message, make the same call with the same input and it runs.`;
61
+ };
62
+ }
63
+
64
+ /** The names of the principals, as far as they are known; ids are never shown. `missing` says some could not be read. */
65
+ export async function namesOf(
66
+ nameOf: ((principalId: string) => Promise<string | undefined>) | undefined,
67
+ principalIds: readonly string[],
68
+ ): Promise<{ names: string[]; missing: boolean }> {
69
+ if (!nameOf) return { names: [], missing: principalIds.length > 0 };
70
+ const names = await Promise.all(
71
+ [...new Set(principalIds)].map(async (id) => {
72
+ try {
73
+ return await nameOf(id);
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }),
78
+ );
79
+ return {
80
+ names: [...new Set(names.filter((name): name is string => !!name))],
81
+ missing: names.some((name) => !name),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The pending hold with `call` added: a partial release kept calls that name approvers, which
87
+ * belong to no speaker, so what this turn holds is its speaker's from now.
88
+ */
89
+ export function remembered(
90
+ current: PendingConfirmation | undefined,
91
+ kept: PendingConfirmation | undefined,
92
+ selectionId: string,
93
+ speaker: SpeakerRef | undefined,
94
+ call: HeldCall,
95
+ ): PendingConfirmation {
96
+ // A partial release kept calls that name approvers, which belong to no speaker: what this turn
97
+ // holds is its speaker's, from now.
98
+ let pending = current;
99
+ if (pending && pending === kept) {
100
+ pending = { ...pending, calls: [...pending.calls] };
101
+ pending.selectionId = selectionId;
102
+ pending.heldAt = new Date();
103
+ delete pending.speakerId;
104
+ delete pending.principalId;
105
+ if (speaker) {
106
+ pending.speakerId = speaker.id;
107
+ pending.principalId = speaker.principalId;
108
+ }
109
+ }
110
+ pending ??= {
111
+ selectionId,
112
+ heldAt: new Date(),
113
+ calls: [],
114
+ ...(speaker
115
+ ? {
116
+ speakerId: speaker.id,
117
+ principalId: speaker.principalId,
118
+ }
119
+ : {}),
120
+ };
121
+ if (
122
+ !pending.calls.some((c) => c.tool === call.tool && c.input === call.input)
123
+ )
124
+ pending.calls.push(call);
125
+ return pending;
126
+ }
127
+
128
+ /** "a", "a or b", "a, b or c". */
129
+ function orList(items: readonly string[]): string {
130
+ const last = items.at(-1);
131
+ if (items.length < 2 || last === undefined) return items.join("");
132
+ return `${items.slice(0, -1).join(", ")} or ${last}`;
133
+ }
@@ -0,0 +1,91 @@
1
+ import type {
2
+ HeldCall,
3
+ PendingConfirmation,
4
+ } from "../../domain/conversation.ts";
5
+ import type { OwnerIdentity } from "../../identity.ts";
6
+ import type { ApprovedBy } from "../../interactions/prompts.ts";
7
+ import type { Speaker } from "../../speakers.ts";
8
+
9
+ /** Held actions older than this cannot be approved any more. */
10
+ export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
11
+
12
+ /** The held actions when they can still be approved at `now`; expired ones count as none. */
13
+ export function unexpired(
14
+ pending: PendingConfirmation | undefined,
15
+ now = new Date(),
16
+ ): PendingConfirmation | undefined {
17
+ if (!pending) return undefined;
18
+ return now.getTime() - pending.heldAt.getTime() > CONFIRMATION_TTL_MS
19
+ ? undefined
20
+ : pending;
21
+ }
22
+
23
+ /** JSON with object keys sorted at every level, so equal inputs compare equal. */
24
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
25
+ export function canonicalJson(value: unknown): string {
26
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
27
+ return JSON.stringify(value, (_key, v: unknown) =>
28
+ isPlainObject(v) ? withSortedKeys(v) : v,
29
+ );
30
+ }
31
+
32
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
33
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
34
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
35
+ }
36
+
37
+ /** The same object with its keys in code-unit order. */
38
+ function withSortedKeys(
39
+ value: Record<string, unknown>,
40
+ ): Record<string, unknown> {
41
+ return Object.fromEntries(
42
+ Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b)),
43
+ );
44
+ }
45
+
46
+ function byCodeUnit(a: string, b: string): number {
47
+ if (a < b) return -1;
48
+ return a > b ? 1 : 0;
49
+ }
50
+
51
+ /** The owner's approving message, preceded by the held calls it releases, so the model makes them again. */
52
+ export function confirmedTurnText(
53
+ pending: PendingConfirmation,
54
+ text: string,
55
+ owner: OwnerIdentity,
56
+ ): string {
57
+ const calls = pending.calls
58
+ .map((call) => `- ${call.action}: ${call.tool} ${call.input}`)
59
+ .join("\n");
60
+ return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
61
+ }
62
+
63
+ /**
64
+ * Whether a call's `approverIds` leave the person free to approve it: always, for a call that names
65
+ * none, since whoever asked decides who the person is; for one that does, only an owner or a named
66
+ * principal. Nobody known, such as a turn without a speaker, may approve a call that names approvers.
67
+ */
68
+ export function mayApproveNamed(
69
+ person:
70
+ | (Pick<Speaker, "principalId"> & { tier?: Speaker["tier"] })
71
+ | undefined,
72
+ call: Pick<HeldCall, "approverIds">,
73
+ ): boolean {
74
+ if (!call.approverIds) return true;
75
+ return (
76
+ person !== undefined &&
77
+ (person.tier === "owner" || call.approverIds.includes(person.principalId))
78
+ );
79
+ }
80
+
81
+ /** Whether an approval reported for a call is one its `approverIds` accept: from a named principal or an owner. */
82
+ export function namedApprover(
83
+ call: Pick<HeldCall, "approverIds">,
84
+ by: ApprovedBy | undefined,
85
+ ): boolean {
86
+ if (!call.approverIds) return true;
87
+ return (
88
+ by !== undefined &&
89
+ (by.owner === true || call.approverIds.includes(by.principalId))
90
+ );
91
+ }
@@ -0,0 +1,108 @@
1
+ import type { ToolTurn } from "../../define.ts";
2
+ import { type HoldCheck, type HoldContext, isPromise } from "../../holds.ts";
3
+
4
+ /** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
5
+ export interface HoldTurn {
6
+ /** Aborts when the turn stops; a call being described then opens no card. */
7
+ signal?: AbortSignal;
8
+ /** The turn a hold receives, with `signal` as the signal that ends its wait. */
9
+ toolTurn(signal: AbortSignal): ToolTurn;
10
+ }
11
+
12
+ /** The answer of a description wait the turn's stop cut short. */
13
+ export const STOPPED = Symbol("stopped");
14
+
15
+ /** What waiting for a call's description comes to: its text, or the turn stopped first. */
16
+ export type Described = string | undefined | typeof STOPPED;
17
+
18
+ /** A held call's description with who may approve it instead of the speaker; see `HoldRule.approversInTurn`. */
19
+ export type Judged =
20
+ | { action: string; approvers?: readonly string[] }
21
+ | undefined
22
+ | typeof STOPPED;
23
+
24
+ /**
25
+ * What a lookup that answers later comes to: its answer; `fallback` when it rejects or outlasts
26
+ * `timeoutMs`; `STOPPED` when the turn stops first. Whatever the lookup is still doing is
27
+ * aborted when the wait ends.
28
+ */
29
+ export function awaited<T>(
30
+ answer: Promise<T>,
31
+ fallback: T,
32
+ lookup: AbortController,
33
+ stop: AbortSignal | undefined,
34
+ timeoutMs: number,
35
+ ): Promise<T | typeof STOPPED> {
36
+ return new Promise((resolve) => {
37
+ const onStop = () => end(STOPPED);
38
+ const timer = setTimeout(() => end(fallback), timeoutMs);
39
+ let ended = false;
40
+ const end = (found: T | typeof STOPPED) => {
41
+ if (ended) return;
42
+ ended = true;
43
+ clearTimeout(timer);
44
+ stop?.removeEventListener("abort", onStop);
45
+ lookup.abort();
46
+ resolve(found);
47
+ };
48
+ // Handled first, so a rejection after the wait ended is never left unhandled.
49
+ answer.then(end, () => end(fallback));
50
+ if (stop?.aborted) return end(STOPPED);
51
+ stop?.addEventListener("abort", onStop, { once: true });
52
+ });
53
+ }
54
+
55
+ /**
56
+ * A description joined by the approvers the holds name for a call they hold, looked up as the
57
+ * turn with the same wait as the description; a lookup that rejects or outlasts the timeout names
58
+ * none, so only the owners approve the call. A call no rule holds asks no one.
59
+ */
60
+ export function joinApprovers(
61
+ described: Described | Promise<Described>,
62
+ from: { holds: HoldCheck; context: HoldContext; timeoutMs: number },
63
+ tool: string,
64
+ input: Record<string, unknown>,
65
+ turn: HoldTurn,
66
+ ): Judged | Promise<Judged> {
67
+ const lookup = from.holds.approversInTurn;
68
+ if (!lookup) return withoutApprovers(described);
69
+ const join = (action: Described): Judged | Promise<Judged> => {
70
+ if (action === STOPPED || action === undefined) return action;
71
+ const controller = new AbortController();
72
+ if (turn.signal?.aborted) controller.abort();
73
+ let named:
74
+ | readonly string[]
75
+ | undefined
76
+ | Promise<readonly string[] | undefined>;
77
+ try {
78
+ named = lookup.call(
79
+ from.holds,
80
+ tool,
81
+ input,
82
+ from.context,
83
+ turn.toolTurn(controller.signal),
84
+ );
85
+ } catch {
86
+ named = [];
87
+ }
88
+ if (!isPromise(named)) {
89
+ controller.abort();
90
+ return named ? { action, approvers: named } : { action };
91
+ }
92
+ return awaited(named, [], controller, turn.signal, from.timeoutMs).then(
93
+ (approvers) => {
94
+ if (approvers === STOPPED) return STOPPED;
95
+ return approvers ? { action, approvers } : { action };
96
+ },
97
+ );
98
+ };
99
+ return isPromise(described) ? described.then(join) : join(described);
100
+ }
101
+
102
+ function withoutApprovers(
103
+ described: Described | Promise<Described>,
104
+ ): Judged | Promise<Judged> {
105
+ const wrap = (action: Described): Judged =>
106
+ action === STOPPED || action === undefined ? action : { action };
107
+ return isPromise(described) ? described.then(wrap) : wrap(described);
108
+ }
@@ -160,7 +160,10 @@ export class PiAgentRuntime implements AgentRuntime {
160
160
  );
161
161
  if (session.thinkingLevel !== level) session.setThinkingLevel(level);
162
162
  const gate = await this.#sessions.gate(key, request.agent !== undefined);
163
- const pending = gate.pending();
163
+ // What this turn's message may release: a held call that named approvers is theirs alone.
164
+ const pending = request.confirmed
165
+ ? gate.releasable(request.speaker)
166
+ : gate.pending();
164
167
  const registered = new Set(session.getAllTools().map((tool) => tool.name));
165
168
  // The speaker's tier limits the tools.
166
169
  const { tier } = request.speaker;
@@ -216,8 +219,9 @@ export class PiAgentRuntime implements AgentRuntime {
216
219
  request.confirmed === true,
217
220
  who,
218
221
  request.speaker,
222
+ conversation.visibility === "private",
219
223
  );
220
- if (request.confirmed && pending) {
224
+ if (request.confirmed && pending && pending.calls.length > 0) {
221
225
  logger.info(
222
226
  {
223
227
  channel: request.channel,
@@ -287,7 +291,7 @@ export class PiAgentRuntime implements AgentRuntime {
287
291
  try {
288
292
  const attachments = request.attachments ?? NO_ATTACHMENTS;
289
293
  const text =
290
- request.confirmed && pending
294
+ request.confirmed && pending && pending.calls.length > 0
291
295
  ? confirmedTurnText(pending, request.text, who)
292
296
  : request.text;
293
297
  const prompted = running.run(() =>
@@ -61,9 +61,17 @@ export class PromptSlot {
61
61
  const prompts = this.#prompts;
62
62
  if (!prompts) return undefined;
63
63
  return {
64
- confirm: (title, message, signal, minTier, wait, details) =>
64
+ confirm: (title, message, signal, minTier, wait, details, options) =>
65
65
  this.#waiting(() =>
66
- prompts.confirm(title, message, signal, minTier, wait, details),
66
+ prompts.confirm(
67
+ title,
68
+ message,
69
+ signal,
70
+ minTier,
71
+ wait,
72
+ details,
73
+ options,
74
+ ),
67
75
  ),
68
76
  ask: (title, question: OwnerQuestion, signal, wait) =>
69
77
  this.#waiting(() => prompts.ask(title, question, signal, wait)),
@@ -370,6 +370,7 @@ export class SessionFactory {
370
370
  },
371
371
  speaker: () => this.#deps.speaker(turnKey),
372
372
  origin: () => this.#deps.origin(turnKey),
373
+ approvedBy: (toolCallId) => gate.approvedBy(toolCallId),
373
374
  client: () => this.#deps.client(turnKey),
374
375
  runTask: (task) =>
375
376
  this.#deps.runTask({ turn: turnKey, home: channel }, task),
@@ -10,6 +10,7 @@ import { type ServiceKey, serviceKey } from "./contract/services.ts";
10
10
  import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
11
11
  import type { PendingConfirmation } from "./domain/conversation.ts";
12
12
  import type { HttpRoute } from "./http/listeners.ts";
13
+ import type { IdentityLinks } from "./identity/identity-links.ts";
13
14
  import type { IdentityService } from "./identity/identity-service.ts";
14
15
  import type { ThinkingSetting } from "./models.ts";
15
16
  import type {
@@ -46,6 +47,16 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
46
47
  */
47
48
  export const IDENTITY: ServiceKey<IdentityService> =
48
49
  serviceKey<IdentityService>("roundtable.identity");
50
+ /**
51
+ * Links a person's own account to their principal, which `IDENTITY` cannot. Provided by the
52
+ * `identity` plugin only when the configuration lists providers in `access.selfLink`; read it with
53
+ * `find` when your plugin works without it, and with `get` when it cannot.
54
+ */
55
+ export const IDENTITY_LINKS: ServiceKey<IdentityLinks> =
56
+ serviceKey<IdentityLinks>("roundtable.identity-links", {
57
+ absent:
58
+ 'Self-linking is switched off. List the providers people may link in the configuration, such as access: { selfLink: { providers: ["discord"] } }.',
59
+ });
49
60
  /**
50
61
  * The runtime every conversation turn runs on: the agent server's, and each turn run through
51
62
  * `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
@@ -3,6 +3,7 @@ import type { ClientPort } from "./domain/client.ts";
3
3
  import { PluginError } from "./errors.ts";
4
4
  import type { Principal } from "./identity/principal-store.ts";
5
5
  import type { OwnerIdentity } from "./identity.ts";
6
+ import type { ApprovedBy } from "./interactions/prompts.ts";
6
7
  import type { Speaker } from "./speakers.ts";
7
8
 
8
9
  /** `<surface>:<channel id>`, for example `discord:1234`. A DM is one channel. */
@@ -99,6 +100,12 @@ export interface SessionContext {
99
100
  * method, which reads as undefined.
100
101
  */
101
102
  origin?(): string | undefined;
103
+ /**
104
+ * Who approved the held call whose tool call has this id, while it runs; undefined for a call
105
+ * that ran without a hold or whose approver the surface did not name. A context built before
106
+ * this method has none, which reads as undefined.
107
+ */
108
+ approvedBy?(toolCallId: string): ApprovedBy | undefined;
102
109
  /**
103
110
  * What the client that started the running turn can do for its tools, as `TurnRequest.client`
104
111
  * says; undefined between turns and for a turn whose transport gives none. A context built
@@ -0,0 +1,194 @@
1
+ import type { InboundMessage } from "../contract/channels.ts";
2
+ import {
3
+ type Approval,
4
+ type ApprovedBy,
5
+ type PromptScope,
6
+ promptScope,
7
+ } from "../interactions/prompts.ts";
8
+ import type { Speaker } from "../speakers.ts";
9
+ import type {
10
+ SurfaceContractAnswerer,
11
+ SurfaceContractSubject,
12
+ SurfaceObservation,
13
+ } from "./surface-contract.ts";
14
+
15
+ export interface Running {
16
+ subject: SurfaceContractSubject;
17
+ delivered: InboundMessage[];
18
+ }
19
+
20
+ export class ContractBroken extends Error {
21
+ override name = "ContractBroken";
22
+ }
23
+
24
+ export function expectThat(condition: boolean, message: string): void {
25
+ if (!condition) throw new ContractBroken(message);
26
+ }
27
+
28
+ export async function until(
29
+ done: () => boolean,
30
+ what: string,
31
+ ms = 2000,
32
+ ): Promise<void> {
33
+ const deadline = Date.now() + ms;
34
+ while (!done()) {
35
+ if (Date.now() > deadline)
36
+ throw new ContractBroken(`timed out waiting until ${what}`);
37
+ await Bun.sleep(5);
38
+ }
39
+ }
40
+
41
+ export const seen = (running: Running, kind: SurfaceObservation["kind"]) =>
42
+ running.subject.observations().filter((o) => o.kind === kind);
43
+
44
+ export const speakerOf = (subject: SurfaceContractSubject): Speaker =>
45
+ subject.speaker ?? {
46
+ id: "contract-speaker",
47
+ name: "Ada",
48
+ tier: "owner",
49
+ principalId: "contract-speaker",
50
+ };
51
+
52
+ /** The person's prompt scope in a conversation of this visibility. */
53
+ export const scopeOf = (
54
+ subject: SurfaceContractSubject,
55
+ visibility: "private" | "shared" = "shared",
56
+ ): PromptScope => promptScope(speakerOf(subject), visibility);
57
+
58
+ /** The newest prompt the person has seen, once one more than `before` is open. */
59
+ export async function nextPrompt(
60
+ running: Running,
61
+ before: number,
62
+ ): Promise<string> {
63
+ await until(
64
+ () => seen(running, "prompt").length > before,
65
+ "the person sees the approval",
66
+ );
67
+ const prompt = seen(running, "prompt").at(-1);
68
+ if (prompt?.kind !== "prompt") throw new ContractBroken("no prompt seen");
69
+ return prompt.id;
70
+ }
71
+
72
+ /** Asks for one approval and answers it as the person does. */
73
+ export async function approval(running: Running, approved: boolean) {
74
+ const { surface, channel, answer } = running.subject;
75
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
76
+ if (!prompts || !answer)
77
+ throw new ContractBroken("the surface shows no prompts");
78
+ const before = seen(running, "prompt").length;
79
+ const result = prompts.confirm(
80
+ "Approve?",
81
+ "Send the report.",
82
+ undefined,
83
+ speakerOf(running.subject).tier,
84
+ );
85
+ await answer(await nextPrompt(running, before), approved);
86
+ return result;
87
+ }
88
+
89
+ /**
90
+ * Opens the person's approval in a conversation of `visibility`, has `other` answer it by its id,
91
+ * and checks that only the person's own answer settles it.
92
+ */
93
+ export async function answeredOnlyByThem(
94
+ running: Running,
95
+ other: SurfaceContractAnswerer | undefined,
96
+ visibility: "private" | "shared",
97
+ approvers?: readonly string[],
98
+ ): Promise<void> {
99
+ const { surface, channel, answer } = running.subject;
100
+ const prompts = surface.prompts?.(
101
+ channel,
102
+ scopeOf(running.subject, visibility),
103
+ );
104
+ if (!prompts || !answer || !other)
105
+ throw new ContractBroken("the surface shows no prompts");
106
+ await other.join?.();
107
+ const before = seen(running, "prompt").length;
108
+ let settled: Approval | undefined;
109
+ const result = prompts
110
+ .confirm(
111
+ "Approve?",
112
+ "Send the report.",
113
+ undefined,
114
+ speakerOf(running.subject).tier,
115
+ undefined,
116
+ undefined,
117
+ approvers ? { approvers } : undefined,
118
+ )
119
+ .then((value) => {
120
+ settled = value;
121
+ return value;
122
+ });
123
+ const id = await nextPrompt(running, before);
124
+ await other.answer(id, true);
125
+ await Bun.sleep(50);
126
+ expectThat(
127
+ settled === undefined,
128
+ `another principal's answer settled the approval as ${settled}`,
129
+ );
130
+ await answer(id, false);
131
+ const own = await result;
132
+ expectThat(own === "declined", `the person's answer came back ${own}`);
133
+ }
134
+
135
+ /**
136
+ * Opens the person's approval in a shared conversation naming the approver, and checks that
137
+ * another person and the person themselves cannot answer it, and the approver can, who is then
138
+ * reported as the approver.
139
+ */
140
+ export async function namedApproverAnswers(running: Running): Promise<void> {
141
+ const { surface, channel, answer, stranger, approver } = running.subject;
142
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
143
+ if (!prompts || !answer || !stranger || !approver)
144
+ throw new ContractBroken("the surface shows no prompts");
145
+ await stranger.join?.();
146
+ await approver.join?.();
147
+ const before = seen(running, "prompt").length;
148
+ let settled: Approval | undefined;
149
+ let reported: ApprovedBy | undefined;
150
+ const result = prompts
151
+ .confirm(
152
+ "Approve?",
153
+ "Send the report.",
154
+ undefined,
155
+ speakerOf(running.subject).tier,
156
+ undefined,
157
+ undefined,
158
+ {
159
+ approvers: [approver.principalId],
160
+ approvedBy: (by) => {
161
+ reported = by;
162
+ },
163
+ },
164
+ )
165
+ .then((value) => {
166
+ settled = value;
167
+ return value;
168
+ });
169
+ const id = await nextPrompt(running, before);
170
+ await stranger.answer(id, true);
171
+ await Bun.sleep(50);
172
+ expectThat(
173
+ settled === undefined,
174
+ `another person's answer settled the named approver's approval as ${settled}`,
175
+ );
176
+ await answer(id, true);
177
+ await Bun.sleep(50);
178
+ expectThat(
179
+ settled === undefined,
180
+ `the speaker's answer settled an approval that names others as ${settled}`,
181
+ );
182
+ await approver.answer(id, true);
183
+ const own = await result;
184
+ expectThat(own === "approved", `the approver's answer came back ${own}`);
185
+ // The runtime accepts an approval of a call that names approvers only from one it names.
186
+ expectThat(
187
+ reported !== undefined,
188
+ "the approval did not report who approved it through approvedBy",
189
+ );
190
+ expectThat(
191
+ reported?.principalId === approver.principalId,
192
+ `the approval reported ${reported?.principalId} as its approver, not ${approver.principalId}`,
193
+ );
194
+ }