pi-roundtable 0.19.2 → 0.21.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 +26 -0
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/docs/plugins.md +51 -5
- package/package.json +2 -1
- package/src/core/discord/card-audience.ts +47 -0
- package/src/core/discord/owner-card-answers.ts +180 -0
- package/src/core/discord/owner-card-asks.ts +149 -0
- package/src/core/discord/owner-card-questionnaire.ts +281 -0
- package/src/core/discord/owner-card-rows.ts +40 -18
- package/src/core/discord/owner-card-types.ts +54 -5
- package/src/core/discord/owner-cards-fixture.ts +37 -3
- package/src/core/discord/owner-cards.ts +62 -133
- package/src/core/discord/utf16.ts +11 -0
- package/src/core/holds.ts +15 -2
- package/src/core/i18n/cards.ts +32 -2
- package/src/core/interactions/prompts.ts +43 -22
- package/src/core/interactions/questionnaire.ts +213 -0
- package/src/core/runtime/ask-questions.ts +93 -0
- package/src/core/runtime/extensions/ask-user-arguments.ts +52 -0
- package/src/core/runtime/extensions/ask-user-text.ts +146 -0
- package/src/core/runtime/extensions/ask-user.ts +87 -81
- package/src/core/runtime/extensions/card-ask.ts +3 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +2 -0
- package/src/core/runtime/extensions/gate-refusals.ts +2 -0
- package/src/core/runtime/extensions/hold-lookups.ts +16 -4
- package/src/core/runtime/prompt-slot.ts +21 -1
- package/src/core/testing/surface-contract-questionnaire.ts +192 -0
- package/src/core/testing/surface-contract-support.ts +29 -1
- package/src/core/testing/surface-contract.ts +46 -20
- package/src/index.ts +11 -0
|
@@ -1,40 +1,16 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Type } from "typebox";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
4
|
+
import type { OwnerQuestion } from "../../interactions/prompts.ts";
|
|
5
|
+
import { QUESTIONNAIRE_LIMITS as L } from "../../interactions/questionnaire.ts";
|
|
6
6
|
import { toolText } from "../../shared/tool-result.ts";
|
|
7
|
+
import { askQuestions } from "../ask-questions.ts";
|
|
7
8
|
import type { PromptSlot } from "../prompt-slot.ts";
|
|
9
|
+
import { foldAskArguments } from "./ask-user-arguments.ts";
|
|
8
10
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
/** The tool's result: what the owner chose and wrote. */
|
|
12
|
-
export function answerText(
|
|
13
|
-
answer: OwnerAnswer | "pending" | undefined,
|
|
14
|
-
owner: OwnerIdentity,
|
|
15
|
-
): string {
|
|
16
|
-
const o = ownerWords(owner);
|
|
17
|
-
if (answer === "pending")
|
|
18
|
-
return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
|
|
19
|
-
if (!answer)
|
|
20
|
-
return `No answer: ${o.name} did not answer. Go on without it if you safely can, otherwise ask in your reply.`;
|
|
21
|
-
const lines = [
|
|
22
|
-
...(answer.choices.length > 0
|
|
23
|
-
? [`${o.name} chose: ${answer.choices.join("; ")}`]
|
|
24
|
-
: []),
|
|
25
|
-
...(answer.text !== undefined ? [`${o.name} wrote: ${answer.text}`] : []),
|
|
26
|
-
];
|
|
27
|
-
return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
|
|
28
|
-
}
|
|
11
|
+
export { answerText, lateAnswerText } from "./ask-user-text.ts";
|
|
29
12
|
|
|
30
|
-
|
|
31
|
-
export function lateAnswerText(
|
|
32
|
-
question: string,
|
|
33
|
-
answer: OwnerAnswer,
|
|
34
|
-
by: string,
|
|
35
|
-
): string {
|
|
36
|
-
return `(${by} answered your earlier ask_user question on its card, after your turn had ended. Continue from where you stopped, with this answer.)\nQuestion: ${question}\n${answerText(answer, { name: by, pronouns: PRONOUNS.they })}`;
|
|
37
|
-
}
|
|
13
|
+
export const ASK_USER_TOOL = "ask_user";
|
|
38
14
|
|
|
39
15
|
/** Registers ask_user, which asks the owner on a card in the turn's channel and waits. */
|
|
40
16
|
export function askUserExtension(
|
|
@@ -45,36 +21,68 @@ export function askUserExtension(
|
|
|
45
21
|
): ExtensionFactory {
|
|
46
22
|
const o = ownerWords(owner);
|
|
47
23
|
return (pi) => {
|
|
24
|
+
const parameters = Type.Object(
|
|
25
|
+
{
|
|
26
|
+
questions: Type.Array(
|
|
27
|
+
Type.Object(
|
|
28
|
+
{
|
|
29
|
+
question: Type.String({
|
|
30
|
+
minLength: 1,
|
|
31
|
+
maxLength: L.questionChars,
|
|
32
|
+
}),
|
|
33
|
+
header: Type.Optional(
|
|
34
|
+
Type.String({
|
|
35
|
+
maxLength: L.headerChars,
|
|
36
|
+
description: "A short name for the question in a set.",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
options: Type.Optional(
|
|
40
|
+
Type.Array(
|
|
41
|
+
Type.Object(
|
|
42
|
+
{
|
|
43
|
+
label: Type.String({
|
|
44
|
+
minLength: 1,
|
|
45
|
+
maxLength: L.labelChars,
|
|
46
|
+
}),
|
|
47
|
+
description: Type.Optional(
|
|
48
|
+
Type.String({ maxLength: L.descriptionChars }),
|
|
49
|
+
),
|
|
50
|
+
preview: Type.Optional(
|
|
51
|
+
Type.String({
|
|
52
|
+
maxLength: L.previewChars,
|
|
53
|
+
description:
|
|
54
|
+
"A markdown mock-up or snippet shown with the option.",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
{ additionalProperties: false },
|
|
59
|
+
),
|
|
60
|
+
{ maxItems: L.options },
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
multi: Type.Optional(
|
|
64
|
+
Type.Boolean({ description: "Several options may be chosen." }),
|
|
65
|
+
),
|
|
66
|
+
allow_other: Type.Optional(
|
|
67
|
+
Type.Boolean({
|
|
68
|
+
description: `${o.He} may write an answer besides the options (default true; false for a closed choice).`,
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
},
|
|
72
|
+
{ additionalProperties: false },
|
|
73
|
+
),
|
|
74
|
+
{ minItems: 1, maxItems: L.questions },
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
{ additionalProperties: false },
|
|
78
|
+
);
|
|
48
79
|
pi.registerTool({
|
|
49
80
|
name: ASK_USER_TOOL,
|
|
50
81
|
label: `Ask ${o.name}`,
|
|
51
|
-
description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to
|
|
52
|
-
parameters
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
options: Type.Optional(
|
|
56
|
-
Type.Array(
|
|
57
|
-
Type.Object(
|
|
58
|
-
{
|
|
59
|
-
label: Type.String({ minLength: 1, maxLength: 100 }),
|
|
60
|
-
description: Type.Optional(Type.String({ maxLength: 100 })),
|
|
61
|
-
},
|
|
62
|
-
{ additionalProperties: false },
|
|
63
|
-
),
|
|
64
|
-
{ maxItems: 25 },
|
|
65
|
-
),
|
|
66
|
-
),
|
|
67
|
-
multi: Type.Optional(
|
|
68
|
-
Type.Boolean({ description: "Several options may be chosen." }),
|
|
69
|
-
),
|
|
70
|
-
allow_other: Type.Optional(
|
|
71
|
-
Type.Boolean({
|
|
72
|
-
description: `${o.He} may write an answer besides the options.`,
|
|
73
|
-
}),
|
|
74
|
-
),
|
|
75
|
-
},
|
|
76
|
-
{ additionalProperties: false },
|
|
77
|
-
),
|
|
82
|
+
description: `Ask ${o.name} a question, or up to four related ones, on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Put every question you have into one call as questions. Offer options when the choices are known, each with a short description, and a preview (markdown) when seeing the option helps; set multi when several may apply, and allow_other to false for a closed choice (otherwise ${o.he} may write ${o.his} own answer). Give each question a short header when you ask several. If ${o.he} does not answer soon, the result says so and the card stays open: then end your turn saying you are waiting, and ${o.his} answer comes in a new turn.`,
|
|
83
|
+
parameters,
|
|
84
|
+
prepareArguments: (args) =>
|
|
85
|
+
foldAskArguments(args) as Static<typeof parameters>,
|
|
78
86
|
execute: async (_toolCallId, params, signal) => {
|
|
79
87
|
const prompts = slot.prompts;
|
|
80
88
|
if (!prompts)
|
|
@@ -82,31 +90,29 @@ export function askUserExtension(
|
|
|
82
90
|
`${o.name} cannot answer buttons in this turn, such as a schedule's. Ask ${o.him} in your reply instead.`,
|
|
83
91
|
);
|
|
84
92
|
const p = params as {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
options: p.options ?? [],
|
|
93
|
-
multi: p.multi === true,
|
|
94
|
-
allowOther: p.allow_other === true,
|
|
93
|
+
questions: {
|
|
94
|
+
question: string;
|
|
95
|
+
header?: string;
|
|
96
|
+
options?: OwnerQuestion["options"];
|
|
97
|
+
multi?: boolean;
|
|
98
|
+
allow_other?: boolean;
|
|
99
|
+
}[];
|
|
95
100
|
};
|
|
96
101
|
return toolText(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
},
|
|
108
|
-
|
|
102
|
+
await askQuestions(
|
|
103
|
+
prompts,
|
|
104
|
+
slot.asker,
|
|
105
|
+
{
|
|
106
|
+
questions: p.questions.map((q) => ({
|
|
107
|
+
question: q.question,
|
|
108
|
+
...(q.header !== undefined ? { header: q.header } : {}),
|
|
109
|
+
options: q.options ?? [],
|
|
110
|
+
multi: q.multi === true,
|
|
111
|
+
allowOther: q.allow_other !== false,
|
|
112
|
+
})),
|
|
113
|
+
},
|
|
109
114
|
addressee?.() ?? owner,
|
|
115
|
+
signal,
|
|
110
116
|
),
|
|
111
117
|
);
|
|
112
118
|
},
|
|
@@ -18,6 +18,8 @@ export interface CardAsk {
|
|
|
18
18
|
wait: PromptWait;
|
|
19
19
|
details: ApprovalDetails;
|
|
20
20
|
approvers: readonly string[] | undefined;
|
|
21
|
+
/** Only the speaker may answer the card; see `HoldDescription`. */
|
|
22
|
+
speakerOnly: boolean;
|
|
21
23
|
/** The call's watch, when its plugin may withdraw it. */
|
|
22
24
|
watch: Watch | undefined;
|
|
23
25
|
}
|
|
@@ -41,6 +43,7 @@ export async function askOnCard(
|
|
|
41
43
|
const found: Omit<CardAnswer, "answer"> = {};
|
|
42
44
|
const options: ApprovalOptions = {
|
|
43
45
|
...(ask.approvers ? { approvers: ask.approvers } : {}),
|
|
46
|
+
...(ask.speakerOnly ? { speakerOnly: true } : {}),
|
|
44
47
|
approvedBy: (by) => {
|
|
45
48
|
found.approvedBy = by;
|
|
46
49
|
},
|
|
@@ -334,6 +334,7 @@ export class ConfirmationGate {
|
|
|
334
334
|
wait: { late: (late) => this.#late(call, late, watch) },
|
|
335
335
|
details: approvalDetails(call, this.#fileRoot, needing.copy),
|
|
336
336
|
approvers: call.approverIds,
|
|
337
|
+
speakerOnly: needing.speakerOnly === true,
|
|
337
338
|
watch,
|
|
338
339
|
},
|
|
339
340
|
);
|
|
@@ -446,6 +447,7 @@ export class ConfirmationGate {
|
|
|
446
447
|
held: call,
|
|
447
448
|
...(judged.copy ? { copy: judged.copy } : {}),
|
|
448
449
|
...(judged.supersede ? { supersede: judged.supersede } : {}),
|
|
450
|
+
...(judged.speakerOnly ? { speakerOnly: judged.speakerOnly } : {}),
|
|
449
451
|
};
|
|
450
452
|
}
|
|
451
453
|
|
|
@@ -34,6 +34,8 @@ export type Needing =
|
|
|
34
34
|
copy?: NonNullable<ApprovalDetails["copy"]>;
|
|
35
35
|
/** Aborts when the plugin withdraws the call; see `HoldDescription`. */
|
|
36
36
|
supersede?: AbortSignal;
|
|
37
|
+
/** The card is the speaker's alone; see `HoldDescription`. */
|
|
38
|
+
speakerOnly?: true;
|
|
37
39
|
}
|
|
38
40
|
| { released: Release };
|
|
39
41
|
|
|
@@ -32,6 +32,8 @@ export interface Judgment {
|
|
|
32
32
|
/** Aborts when the plugin withdraws the call; see `HoldDescription`. */
|
|
33
33
|
supersede?: AbortSignal;
|
|
34
34
|
approvers?: readonly string[];
|
|
35
|
+
/** The card is the speaker's alone; see `HoldDescription`. */
|
|
36
|
+
speakerOnly?: true;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export type Judged = Judgment | undefined | typeof STOPPED;
|
|
@@ -55,13 +57,16 @@ function isSignal(value: unknown): value is AbortSignal {
|
|
|
55
57
|
export function described(
|
|
56
58
|
answer: HoldDescription,
|
|
57
59
|
tool: string,
|
|
58
|
-
): Pick<Judgment, "action" | "copy" | "supersede"> {
|
|
60
|
+
): Pick<Judgment, "action" | "copy" | "supersede" | "speakerOnly"> {
|
|
59
61
|
if (typeof answer === "string") return { action: answer };
|
|
60
62
|
if (typeof answer !== "object" || answer === null)
|
|
61
63
|
return { action: messages().holdGeneric(tool) };
|
|
62
|
-
const { text, copy, supersede } = answer;
|
|
64
|
+
const { text, copy, supersede, approval } = answer;
|
|
63
65
|
if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
|
|
64
|
-
const withdrawal =
|
|
66
|
+
const withdrawal = {
|
|
67
|
+
...(isSignal(supersede) ? { supersede } : {}),
|
|
68
|
+
...(approval === "speaker" ? { speakerOnly: true as const } : {}),
|
|
69
|
+
};
|
|
65
70
|
if (
|
|
66
71
|
!copy ||
|
|
67
72
|
typeof copy !== "object" ||
|
|
@@ -125,7 +130,12 @@ export function joinApprovers(
|
|
|
125
130
|
if (!lookup) return withoutApprovers(answer, tool);
|
|
126
131
|
const join = (action: Described): Judged | Promise<Judged> => {
|
|
127
132
|
if (action === STOPPED || action === undefined) return action;
|
|
128
|
-
const {
|
|
133
|
+
const {
|
|
134
|
+
action: text,
|
|
135
|
+
copy,
|
|
136
|
+
supersede,
|
|
137
|
+
speakerOnly,
|
|
138
|
+
} = described(action, tool);
|
|
129
139
|
const controller = new AbortController();
|
|
130
140
|
if (turn.signal?.aborted) controller.abort();
|
|
131
141
|
let named:
|
|
@@ -149,6 +159,7 @@ export function joinApprovers(
|
|
|
149
159
|
action: text,
|
|
150
160
|
...(copy ? { copy } : {}),
|
|
151
161
|
...(supersede ? { supersede } : {}),
|
|
162
|
+
...(speakerOnly ? { speakerOnly } : {}),
|
|
152
163
|
...(named ? { approvers: named } : {}),
|
|
153
164
|
};
|
|
154
165
|
}
|
|
@@ -159,6 +170,7 @@ export function joinApprovers(
|
|
|
159
170
|
action: text,
|
|
160
171
|
...(copy ? { copy } : {}),
|
|
161
172
|
...(supersede ? { supersede } : {}),
|
|
173
|
+
...(speakerOnly ? { speakerOnly } : {}),
|
|
162
174
|
...(approvers ? { approvers } : {}),
|
|
163
175
|
};
|
|
164
176
|
},
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { HoldRefusal } from "../domain/progress.ts";
|
|
2
2
|
import { assistantName } from "../i18n/index.ts";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
OwnerQuestion,
|
|
5
|
+
Prompts,
|
|
6
|
+
PromptWait,
|
|
7
|
+
Questionnaire,
|
|
8
|
+
} from "../interactions/prompts.ts";
|
|
4
9
|
import { turnBudget } from "./turn-budget.ts";
|
|
5
10
|
|
|
6
11
|
/**
|
|
@@ -61,7 +66,22 @@ export class PromptSlot {
|
|
|
61
66
|
get prompts(): Prompts | undefined {
|
|
62
67
|
const prompts = this.#prompts;
|
|
63
68
|
if (!prompts) return undefined;
|
|
69
|
+
const { questionnaire } = prompts;
|
|
64
70
|
return {
|
|
71
|
+
...(prompts.asks && questionnaire
|
|
72
|
+
? {
|
|
73
|
+
asks: prompts.asks,
|
|
74
|
+
questionnaire: (
|
|
75
|
+
title: string,
|
|
76
|
+
asked: Questionnaire,
|
|
77
|
+
signal?: AbortSignal,
|
|
78
|
+
wait?: PromptWait,
|
|
79
|
+
) =>
|
|
80
|
+
this.#waiting(() =>
|
|
81
|
+
questionnaire.call(prompts, title, asked, signal, wait),
|
|
82
|
+
),
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
65
85
|
confirm: (title, message, signal, minTier, wait, details, options) =>
|
|
66
86
|
this.#waiting(() =>
|
|
67
87
|
prompts.confirm(
|
|
@@ -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
|
+
}
|
|
@@ -95,6 +95,7 @@ export async function answeredOnlyByThem(
|
|
|
95
95
|
other: SurfaceContractAnswerer | undefined,
|
|
96
96
|
visibility: "private" | "shared",
|
|
97
97
|
approvers?: readonly string[],
|
|
98
|
+
speakerOnly = false,
|
|
98
99
|
): Promise<void> {
|
|
99
100
|
const { surface, channel, answer } = running.subject;
|
|
100
101
|
const prompts = surface.prompts?.(
|
|
@@ -114,7 +115,12 @@ export async function answeredOnlyByThem(
|
|
|
114
115
|
speakerOf(running.subject).tier,
|
|
115
116
|
undefined,
|
|
116
117
|
undefined,
|
|
117
|
-
approvers
|
|
118
|
+
approvers || speakerOnly
|
|
119
|
+
? {
|
|
120
|
+
...(approvers ? { approvers } : {}),
|
|
121
|
+
...(speakerOnly ? { speakerOnly } : {}),
|
|
122
|
+
}
|
|
123
|
+
: undefined,
|
|
118
124
|
)
|
|
119
125
|
.then((value) => {
|
|
120
126
|
settled = value;
|
|
@@ -192,3 +198,25 @@ export async function namedApproverAnswers(running: Running): Promise<void> {
|
|
|
192
198
|
`the approval reported ${reported?.principalId} as its approver, not ${approver.principalId}`,
|
|
193
199
|
);
|
|
194
200
|
}
|
|
201
|
+
|
|
202
|
+
/** Runs one check on a fresh subject from `make`, starting and stopping its surface around it. */
|
|
203
|
+
export async function runCheck(
|
|
204
|
+
check: {
|
|
205
|
+
applies?(subject: SurfaceContractSubject): boolean;
|
|
206
|
+
run(running: Running): Promise<void>;
|
|
207
|
+
},
|
|
208
|
+
make: () => Promise<SurfaceContractSubject>,
|
|
209
|
+
): Promise<"passed" | "skipped"> {
|
|
210
|
+
const subject = await make();
|
|
211
|
+
const running: Running = { subject, delivered: [] };
|
|
212
|
+
try {
|
|
213
|
+
if (check.applies && !check.applies(subject)) return "skipped";
|
|
214
|
+
await subject.surface.start((message) => running.delivered.push(message));
|
|
215
|
+
await subject.join?.();
|
|
216
|
+
await check.run(running);
|
|
217
|
+
return "passed";
|
|
218
|
+
} finally {
|
|
219
|
+
await subject.close?.();
|
|
220
|
+
await subject.surface.stop?.();
|
|
221
|
+
}
|
|
222
|
+
}
|