pi-roundtable 0.19.1 → 0.20.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.
@@ -0,0 +1,192 @@
1
+ import { describe, test } from "bun:test";
2
+ import {
3
+ type AskSupport,
4
+ type Questionnaire,
5
+ type QuestionnaireAnswer,
6
+ shapeFor,
7
+ } from "../interactions/questionnaire.ts";
8
+ import type { SurfaceContractSubject } from "./surface-contract.ts";
9
+ import {
10
+ ContractBroken,
11
+ expectThat,
12
+ nextPrompt,
13
+ type Running,
14
+ runCheck,
15
+ scopeOf,
16
+ seen,
17
+ until,
18
+ } from "./surface-contract-support.ts";
19
+
20
+ /** One check of the questionnaire part of the surface contract. */
21
+ export interface QuestionnaireCheck {
22
+ name: string;
23
+ applies(subject: SurfaceContractSubject): boolean;
24
+ run(running: Running): Promise<void>;
25
+ }
26
+
27
+ /** The set the checks ask: two questions, the first with previews where the surface shows them. */
28
+ function contractSet(asks: AskSupport): Questionnaire {
29
+ const preview = asks.previews ? { preview: "```\nlayout\n```" } : {};
30
+ const day = {
31
+ question: "Which day?",
32
+ header: "Day",
33
+ options: [
34
+ { label: "Saturday", ...preview },
35
+ { label: "Sunday", ...preview },
36
+ ],
37
+ multi: false,
38
+ allowOther: true,
39
+ };
40
+ const food = {
41
+ question: "Which food?",
42
+ options: [{ label: "Rice" }, { label: "Noodles" }],
43
+ multi: true,
44
+ allowOther: false,
45
+ };
46
+ return shapeFor(
47
+ { questions: asks.questions >= 2 ? [day, food] : [day] },
48
+ asks,
49
+ );
50
+ }
51
+
52
+ /** The person's prompts, once the surface has started; undefined where it declares no `asks`. */
53
+ function questionnairePrompts(running: Running) {
54
+ const { surface, channel } = running.subject;
55
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
56
+ if (!prompts?.asks || !prompts.questionnaire)
57
+ throw new ContractBroken(
58
+ "the subject answers questionnaires, but its prompts declare no asks",
59
+ );
60
+ return { prompts, asks: prompts.asks, ask: prompts.questionnaire };
61
+ }
62
+
63
+ const answerOf = (
64
+ set: Questionnaire,
65
+ asks: AskSupport,
66
+ ): QuestionnaireAnswer => ({
67
+ answers: set.questions.map((_, i) =>
68
+ i === 0 ? { choices: ["Sunday"] } : undefined,
69
+ ),
70
+ ...(asks.note ? { note: "thanks" } : {}),
71
+ });
72
+
73
+ const applies = (subject: SurfaceContractSubject) =>
74
+ subject.surface.prompts !== undefined &&
75
+ subject.answerQuestionnaire !== undefined;
76
+
77
+ /**
78
+ * The questionnaire checks. They apply to a subject that gives `answerQuestionnaire`, which must declare `asks`;
79
+ * a surface that takes one question at a time gives none and is skipped.
80
+ */
81
+ export const QUESTIONNAIRE_CHECKS: readonly QuestionnaireCheck[] = [
82
+ {
83
+ name: "a questionnaire resolves as the person answers it, keeps a blank question blank, and closes",
84
+ applies,
85
+ run: async (running) => {
86
+ const found = questionnairePrompts(running);
87
+ const answer = running.subject.answerQuestionnaire;
88
+ if (!answer) return;
89
+ const set = contractSet(found.asks);
90
+ const before = seen(running, "prompt").length;
91
+ const result = found.ask("Questions", set);
92
+ const id = await nextPrompt(running, before);
93
+ const given = answerOf(set, found.asks);
94
+ await answer(id, given, set);
95
+ const got = await result;
96
+ if (got === "pending" || got === undefined)
97
+ throw new ContractBroken(`the person's answers came back ${got}`);
98
+ expectThat(
99
+ JSON.stringify(got.answers.map((a) => a ?? null)) ===
100
+ JSON.stringify(given.answers.map((a) => a ?? null)),
101
+ `the answers came back as ${JSON.stringify(got.answers)}`,
102
+ );
103
+ expectThat(
104
+ got.note === given.note,
105
+ `the note came back as ${JSON.stringify(got.note)}`,
106
+ );
107
+ await until(
108
+ () =>
109
+ seen(running, "prompt_closed").some(
110
+ (o) => o.kind === "prompt_closed" && o.id === id,
111
+ ),
112
+ "the answered questionnaire closes",
113
+ );
114
+ },
115
+ },
116
+ {
117
+ name: "a stopped turn's questionnaire resolves with no answer and closes",
118
+ applies,
119
+ run: async (running) => {
120
+ const found = questionnairePrompts(running);
121
+ const stop = new AbortController();
122
+ const before = seen(running, "prompt").length;
123
+ const result = found.ask(
124
+ "Questions",
125
+ contractSet(found.asks),
126
+ stop.signal,
127
+ );
128
+ const id = await nextPrompt(running, before);
129
+ stop.abort();
130
+ const got = await result;
131
+ expectThat(
132
+ got === undefined,
133
+ `a stopped turn's questionnaire got ${got}`,
134
+ );
135
+ await until(
136
+ () =>
137
+ seen(running, "prompt_closed").some(
138
+ (o) => o.kind === "prompt_closed" && o.id === id,
139
+ ),
140
+ "the stopped questionnaire closes",
141
+ );
142
+ },
143
+ },
144
+ {
145
+ name: "another person cannot answer the person's questionnaire",
146
+ applies: (subject) =>
147
+ applies(subject) && subject.stranger?.answerQuestionnaire !== undefined,
148
+ run: async (running) => {
149
+ const found = questionnairePrompts(running);
150
+ const stranger = running.subject.stranger;
151
+ const answer = running.subject.answerQuestionnaire;
152
+ if (!answer || !stranger?.answerQuestionnaire) return;
153
+ await stranger.join?.();
154
+ const set = contractSet(found.asks);
155
+ const before = seen(running, "prompt").length;
156
+ let settled: unknown;
157
+ const result = found.ask("Questions", set).then((value) => {
158
+ settled = value;
159
+ return value;
160
+ });
161
+ const id = await nextPrompt(running, before);
162
+ await stranger.answerQuestionnaire(id, answerOf(set, found.asks), set);
163
+ await Bun.sleep(50);
164
+ expectThat(
165
+ settled === undefined,
166
+ "another person's answer settled the questionnaire",
167
+ );
168
+ await answer(id, answerOf(set, found.asks), set);
169
+ const own = await result;
170
+ expectThat(
171
+ own !== "pending" && own !== undefined,
172
+ `the person's answer came back ${own}`,
173
+ );
174
+ },
175
+ },
176
+ ];
177
+
178
+ /**
179
+ * Registers only the questionnaire part of the contract, for a card layer without a chat around
180
+ * it. Internal: the published contract is `describeSurfaceContract`.
181
+ */
182
+ export function describeQuestionnaireContract(
183
+ name: string,
184
+ make: () => Promise<SurfaceContractSubject>,
185
+ ): void {
186
+ describe(`${name} keeps the questionnaire contract`, () => {
187
+ for (const check of QUESTIONNAIRE_CHECKS)
188
+ test(check.name, async () => {
189
+ await runCheck(check, make);
190
+ });
191
+ });
192
+ }
@@ -192,3 +192,25 @@ export async function namedApproverAnswers(running: Running): Promise<void> {
192
192
  `the approval reported ${reported?.principalId} as its approver, not ${approver.principalId}`,
193
193
  );
194
194
  }
195
+
196
+ /** Runs one check on a fresh subject from `make`, starting and stopping its surface around it. */
197
+ export async function runCheck(
198
+ check: {
199
+ applies?(subject: SurfaceContractSubject): boolean;
200
+ run(running: Running): Promise<void>;
201
+ },
202
+ make: () => Promise<SurfaceContractSubject>,
203
+ ): Promise<"passed" | "skipped"> {
204
+ const subject = await make();
205
+ const running: Running = { subject, delivered: [] };
206
+ try {
207
+ if (check.applies && !check.applies(subject)) return "skipped";
208
+ await subject.surface.start((message) => running.delivered.push(message));
209
+ await subject.join?.();
210
+ await check.run(running);
211
+ return "passed";
212
+ } finally {
213
+ await subject.close?.();
214
+ await subject.surface.stop?.();
215
+ }
216
+ }
@@ -3,8 +3,13 @@ import type { ChatSurface } from "../contract/surface.ts";
3
3
  import { parseChannelKey } from "../contract/surface.ts";
4
4
  import type { TurnProgress } from "../domain/progress.ts";
5
5
  import type { Approval } from "../interactions/prompts.ts";
6
+ import type {
7
+ Questionnaire,
8
+ QuestionnaireAnswer,
9
+ } from "../interactions/questionnaire.ts";
6
10
  import type { ChannelKey } from "../sessions.ts";
7
11
  import type { Speaker } from "../speakers.ts";
12
+ import { QUESTIONNAIRE_CHECKS } from "./surface-contract-questionnaire.ts";
8
13
  import {
9
14
  answeredOnlyByThem,
10
15
  approval,
@@ -13,6 +18,7 @@ import {
13
18
  namedApproverAnswers,
14
19
  nextPrompt,
15
20
  type Running,
21
+ runCheck,
16
22
  scopeOf,
17
23
  seen,
18
24
  speakerOf,
@@ -44,6 +50,12 @@ export interface SurfaceContractSubject {
44
50
  observations(): readonly SurfaceObservation[];
45
51
  /** The person answers an open approval; without it the prompt checks are skipped. */
46
52
  answer?(prompt: string, approved: boolean): Promise<void>;
53
+ /** The person answers an open questionnaire, as shaped for `asks`; without it those checks are skipped. */
54
+ answerQuestionnaire?(
55
+ prompt: string,
56
+ answer: QuestionnaireAnswer,
57
+ questionnaire: Questionnaire,
58
+ ): Promise<void>;
47
59
  /**
48
60
  * The person as the speaker of the turns that ask them, given to `surface.prompts` as the
49
61
  * scope `promptScope(speaker, visibility)`; default an owner-tier speaker. Every approval the
@@ -85,6 +97,12 @@ export interface SurfaceContractAnswerer {
85
97
  /** They join the surface, once it has started. */
86
98
  join?(): Promise<void>;
87
99
  answer(prompt: string, approved: boolean): Promise<void>;
100
+ /** They answer an open questionnaire; without it its stranger check is skipped. */
101
+ answerQuestionnaire?(
102
+ prompt: string,
103
+ answer: QuestionnaireAnswer,
104
+ questionnaire: Questionnaire,
105
+ ): Promise<void>;
88
106
  }
89
107
 
90
108
  /** One way the subject broke the contract. */
@@ -401,26 +419,9 @@ const CHECKS: readonly Check[] = [
401
419
  );
402
420
  },
403
421
  },
422
+ ...QUESTIONNAIRE_CHECKS,
404
423
  ];
405
424
 
406
- async function runCheck(
407
- check: Check,
408
- make: () => Promise<SurfaceContractSubject>,
409
- ): Promise<"passed" | "skipped"> {
410
- const subject = await make();
411
- const running: Running = { subject, delivered: [] };
412
- try {
413
- if (check.applies && !check.applies(subject)) return "skipped";
414
- await subject.surface.start((message) => running.delivered.push(message));
415
- await subject.join?.();
416
- await check.run(running);
417
- return "passed";
418
- } finally {
419
- await subject.close?.();
420
- await subject.surface.stop?.();
421
- }
422
- }
423
-
424
425
  /**
425
426
  * Runs the chat surface contract on fresh subjects from `make`, one per check, and returns the
426
427
  * checks the surface broke. `describeSurfaceContract` registers the same checks as tests.
@@ -449,7 +450,8 @@ export async function checkSurfaceContract(
449
450
  * too, and lets an approval above the person's tier there come back at once, shown to no one.
450
451
  * Where it can name approvers, an approval that does is answered by them, reported as the
451
452
  * approver, and not by the person or another person, and a private conversation's is answered by
452
- * its person alone, whoever the call names besides them. Each
453
+ * its person alone, whoever the call names besides them. A subject that gives
454
+ * `answerQuestionnaire` must declare `asks`, and then questionnaires keep the same rules. Each
453
455
  * check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
454
456
  */
455
457
  export function describeSurfaceContract(
package/src/index.ts CHANGED
@@ -195,6 +195,7 @@ export type {
195
195
  ApprovalOptions,
196
196
  ApprovedBy,
197
197
  AskOption,
198
+ AskSupport,
198
199
  LateAnswer,
199
200
  OwnerAnswer,
200
201
  OwnerPrompts,
@@ -202,8 +203,18 @@ export type {
202
203
  PromptScope,
203
204
  Prompts,
204
205
  PromptWait,
206
+ Questionnaire,
207
+ QuestionnaireAnswer,
205
208
  } from "./core/interactions/prompts.ts";
206
209
  export { promptScope, withdrawalNote } from "./core/interactions/prompts.ts";
210
+ export {
211
+ checkAnswer,
212
+ checkQuestionnaireAnswer,
213
+ QUESTIONNAIRE_LIMITS,
214
+ SINGLE_QUESTION,
215
+ shapeFor,
216
+ validateQuestionnaire,
217
+ } from "./core/interactions/questionnaire.ts";
207
218
  export type { JudgeModel } from "./core/judging/model-judge.ts";
208
219
  export type { LogEntry, LogFn, Logger } from "./core/log.ts";
209
220
  export type { ThinkingLevel, ThinkingSetting } from "./core/models.ts";