pi-roundtable-webchat 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.20.0] - 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - Questionnaires: `ready.prompts` advertises them, a client that sees it may send `prompt_features`, and a `questionnaire` prompt is sent only to connections that declared support covering it. The client answers with a new `answers` frame (`bad_frame` for a malformed or non-fitting answer, `forbidden` from another person). The protocol name and `ready.protocol` stay v1, so old clients and old servers keep working; a person whose tabs have not declared is asked one question at a time. A questionnaire that no open connection can show (the declaring tab closed or narrowed its declaration, or none was connected) waits 60 seconds for one to declare, then expires so the model is told it went unanswered and a re-ask uses the one-question fallback. The public `ClientFrame`, `ServerFrame` and `PromptFrame` types gain `answers`, `prompt_features`, `kind: "questionnaire"` and `ready.prompts`, so a TypeScript client that switches exhaustively on `kind` needs a new case. This needs `pi-roundtable` 0.20.
13
+
14
+ ### Changed
15
+
16
+ - An `answer` is checked by the core's shared `checkAnswer`; its behaviour is unchanged.
17
+
18
+ ## [0.19.2] - 2026-10-18
19
+
20
+ - Release in lockstep with pi-roundtable 0.19.2. The core peer range stays `>=0.19.0 <0.20.0`.
21
+
8
22
  ## [0.19.1] - 2026-10-18
9
23
 
10
24
  - Release in lockstep with pi-roundtable 0.19.1. The core peer range stays `>=0.19.0 <0.20.0`.
package/README.md CHANGED
@@ -224,6 +224,8 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
224
224
  | `{ type: "stop", conversation }` | Stops the conversation's running turn. |
225
225
  | `{ type: "approval", prompt, approved }` | Approves or declines an approval prompt. |
226
226
  | `{ type: "answer", prompt, choices, text? }` | Answers a question prompt: the chosen options' labels, and your own text where the question allows it. |
227
+ | `{ type: "answers", prompt, answers, note? }` | Answers a `questionnaire` prompt: `answers` has one entry per question in order, each `{ choices, text? }` as for `answer`, or `null` for a question left blank (at least one answer or a `note` is needed); `note` is a note on the whole set. See [Questionnaires](#questionnaires). |
228
+ | `{ type: "prompt_features", ask }` | Declares what this connection can show of a questionnaire: `ask` is `{ questions, previews, note }` (`questions` 1 to 4). Send it only after a `ready` that has `prompts`; a server before 0.20 would refuse it as `bad_frame`. A connection that never declares is never sent a `questionnaire` prompt. |
227
229
  | `{ type: "auth", token }` | A fresh token for the same person, sent after `reauth`. |
228
230
  | `{ type: "client_hello", version: 1, tools }` | Declares the functions this connection can run for the assistant's tools, such as reading the page the person has open: up to 64 names, each lowercase words joined by underscores. Send it after each `ready`. See [Client calls](#client-calls). |
229
231
  | `{ type: "client_result", call, ok: true, value, parts? }` or `{ type: "client_result", call, ok: false, code, message? }` | The answer to a `client_call`: the JSON `value` and how many binary `parts` were uploaded first, or a failure `code` (`refused`, `failed`, `timeout`, or `stopped`) with a short `message`. |
@@ -232,14 +234,14 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
232
234
 
233
235
  | Frame | Meaning |
234
236
  |---|---|
235
- | `{ type: "ready", protocol, speaker, personas, attachments, clientCalls, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), what a client call may take (`clientCalls`, below; absent from a server before 0.9.11, which never sends calls), and when the token expires. Open prompts follow it. |
237
+ | `{ type: "ready", protocol, speaker, personas, attachments, clientCalls, prompts?, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), what a client call may take (`clientCalls`, below; absent from a server before 0.9.11, which never sends calls), what questionnaires it can send (`prompts`: `{ ask: { questions, previews, note } }`; absent from a server before 0.20, so send no `prompt_features` then), and when the token expires. Open prompts follow it; a `questionnaire` follows only to a connection that already declared (on a reauth `ready`), and a fresh connection gets it after its `prompt_features`. |
236
238
  | `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
237
239
  | `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
238
240
  | `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
239
241
  | `{ type: "progress", conversation, event }` | What the running turn writes and which tools it runs: `{ type: "text", delta }`, `{ type: "tool_start", id, tool, preview? }`, or `{ type: "tool_end", id, tool, ok, refused? }`. Never the thinking, never a tool's full arguments. A `tool_end` that a hold refused (not a real failure) has `ok: false` and `refused`: `declined` (turned down on its approval card), `expired` (a card was shown and went unanswered; the call is held for the next message), `pending` (the card is still open), `withdrawn` (the plugin that held the call took it back) or `held` (no card could be shown: the person's tier is below the call's, or it could not be posted; the call is held for the next message). `refused` is absent for a success and for a real failure. |
240
242
  | `{ type: "reply", conversation, text, thinking?, files? }` | The turn's answer in full markdown, with its files inline as `{ name, data }` (base64). |
241
243
  | `{ type: "failed", conversation, stopped }` | The turn ended without an answer: it failed, the host refused it before it ran (for example when its conversation could not be recorded), or it was stopped. The cause stays in the host's log. |
242
- | `{ type: "prompt", conversation, prompt }` | The turn asks you: `{ id, kind: "approval", title, message, approval? }`, or `{ id, kind: "ask", title, question, options, multi, allowOther }`. |
244
+ | `{ type: "prompt", conversation, prompt }` | The turn asks you: `{ id, kind: "approval", title, message, approval? }`, `{ id, kind: "ask", title, question, options, multi, allowOther }`, or `{ id, kind: "questionnaire", title, questions }` (only to connections that declared `prompt_features`; see [Questionnaires](#questionnaires)). |
243
245
  | `{ type: "prompt_closed", conversation, prompt, outcome, note? }` | The prompt closed: `approved`, `declined`, `answered`, `expired`, `cancelled` (the turn stopped), or `withdrawn` (the plugin that asked took the approval back; `note` may carry its reason in a few words). |
244
246
  | `{ type: "notice", notice: { id, text, createdAt, readAt } }` | A durable private inbox entry; `readAt` is `null` until read. Fetch the REST inbox to recover entries missed while offline. |
245
247
  | `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
@@ -263,7 +265,7 @@ approval?: {
263
265
 
264
266
  The frame's size is bounded: when `approval` serialized as UTF-8 JSON would pass 256 KiB (`APPROVAL_MAX_BYTES` in `protocol.ts`), every string value of `input` longer than what fits is cut to its start plus `… [N chars]` (N counts code points; a cut never splits a surrogate pair) and `approval.truncated` is `true`. Then `input` is no longer whole: show `message`, or tell the person it is cut. If even the shortest cut cannot fit, `input` is `{}` and `files` are left out.
265
267
 
266
- Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), `unknown_call` (a `client_result` named a call that is not open on this connection: it ended, was answered already, or was sent to another connection), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
268
+ Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an `answer` or `answers` the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), `unknown_call` (a `client_result` named a call that is not open on this connection: it ended, was answered already, or was sent to another connection), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
267
269
 
268
270
  Close codes: `4401` when the token expired without a fresh `auth`, or a fresh one was refused; `4403` when the person is no longer admitted, or a fresh token names someone else.
269
271
  The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
@@ -336,6 +338,18 @@ This needs a pi-roundtable release that has `attachments.retention`; on an earli
336
338
  Uploads are as private as the transcript: files sit in the host's data directory under the private conversation, where the owner and the operator can read them, as they can the transcript.
337
339
  Only the person who uploaded a file can use it, and only in the conversation it was uploaded to; a shared conversation (one recorded for no one in particular) takes no uploads.
338
340
 
341
+ ## Questionnaires
342
+
343
+ A turn may ask up to four related questions on one card. The protocol name and `ready.protocol` stay `roundtable.webchat.v1`; the change is additive:
344
+
345
+ 1. A server from 0.20 adds `prompts: { ask: { questions: 4, previews: true, note: true } }` to `ready`. A client that sees it may send `prompt_features` with what it can show. A client that does not see it sends nothing new.
346
+ 2. The server sends a `prompt` with `kind: "questionnaire"`, `title`, and `questions` (each `{ question, header?, options, multi, allowOther }`, an option being `{ label, description?, preview? }`), **only to connections that declared support covering it**. A client that never declared never receives one, while connected, after a reconnect, or beside a tab that did declare. On a reconnect the open questionnaire is sent after the declaration, not with the other open prompts.
347
+ 3. What the assistant may ask is the least of the person's declaring tabs. When no tab declared, the assistant asks one question at a time through the `ask` prompt, as before.
348
+ 4. The client answers with `answers`. `bad_frame` for a frame that does not parse or an answer the questions do not allow, `forbidden` from someone other than the conversation's person.
349
+ 5. A questionnaire that no open connection can show settles `expired` (a `prompt_closed` to the connections it was sent to) after 60 seconds without a covering declaration: it settles unanswered, the model is told, and if it asks again it gets the one-at-a-time fallback.
350
+
351
+ A client must ignore a prompt `kind` it does not know.
352
+
339
353
  ## Client calls
340
354
 
341
355
  A tool may need something only the person's own client can do, such as a browser extension reading the page they have open.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-webchat",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,14 +38,14 @@
38
38
  "jose": "6.2.12"
39
39
  },
40
40
  "peerDependencies": {
41
- "pi-roundtable": ">=0.19.0 <0.20.0"
41
+ "pi-roundtable": ">=0.20.0 <0.21.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@biomejs/biome": "2.5.15",
45
45
  "@earendil-works/pi-ai": ">=1.0.0 <2",
46
46
  "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
47
47
  "@types/bun": "1.4.3",
48
- "pi-roundtable": "0.19.1",
48
+ "pi-roundtable": "0.20.0",
49
49
  "typebox": "1.3.37",
50
50
  "typescript": "7.0.2"
51
51
  }
package/src/chat.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  } from "./oidc.ts";
33
33
  import { PromptDesk } from "./prompts.ts";
34
34
  import {
35
+ ADVERTISED_ASK,
35
36
  CLOSE_CODES,
36
37
  type ClientFrame,
37
38
  type ErrorCode,
@@ -85,6 +86,8 @@ export interface WebChatLimits extends UploadLimits {
85
86
 
86
87
  export interface WebChatDeps {
87
88
  surface: string;
89
+ /** How long a questionnaire no connection can show waits for one before it expires; default 60 seconds. */
90
+ promptGraceMs?: number;
88
91
  verifier: TokenVerifier;
89
92
  identity(): IdentityService;
90
93
  personas: readonly WebPersona[];
@@ -240,7 +243,14 @@ export class WebChat {
240
243
  this.connections.sendTo(principal, frame);
241
244
  this.desk = new PromptDesk({
242
245
  send,
246
+ sendWhere: (principal, frame, where) =>
247
+ this.connections.sendTo(principal, frame, where),
248
+ supports: (principal) => this.connections.askSupports(principal),
249
+ sendOne: (connection, frame) => this.connections.send(connection, frame),
243
250
  timeoutMs: deps.limits.promptTimeoutMs,
251
+ ...(deps.promptGraceMs === undefined
252
+ ? {}
253
+ : { graceMs: deps.promptGraceMs }),
244
254
  });
245
255
  this.clients = new ClientDesk({
246
256
  limits: deps.limits.clientCalls,
@@ -436,9 +446,13 @@ export class WebChat {
436
446
  personas: this.personasFor(speaker),
437
447
  attachments: this.uploads.advertised,
438
448
  clientCalls: this.clients.advertised,
449
+ prompts: { ask: ADVERTISED_ASK },
439
450
  expiresAt: identity.expiresAt.toISOString(),
440
451
  });
441
- for (const frame of this.desk.openFor(speaker.principalId))
452
+ for (const frame of this.desk.openFor(
453
+ speaker.principalId,
454
+ connection.askSupport,
455
+ ))
442
456
  this.connections.send(connection, frame);
443
457
  this.#watchToken(connection);
444
458
  }
@@ -477,6 +491,7 @@ export class WebChat {
477
491
  closed(socket: RouteSocket<Connection>): void {
478
492
  this.clients.closed(socket.data);
479
493
  this.connections.closed(socket.data);
494
+ this.desk.closed(socket.data.speaker.principalId);
480
495
  }
481
496
 
482
497
  /** One frame from a socket; a refused frame is answered with `error`, never thrown. */
@@ -497,7 +512,9 @@ export class WebChat {
497
512
  const ref =
498
513
  frame.type === "send"
499
514
  ? frame.id
500
- : frame.type === "approval" || frame.type === "answer"
515
+ : frame.type === "approval" ||
516
+ frame.type === "answer" ||
517
+ frame.type === "answers"
501
518
  ? frame.prompt
502
519
  : frame.type === "client_result"
503
520
  ? frame.call
@@ -550,6 +567,12 @@ export class WebChat {
550
567
  if (refused) throw new Refusal(refused);
551
568
  return;
552
569
  }
570
+ case "prompt_features":
571
+ case "answers": {
572
+ const refused = this.desk.receive(connection, speaker, frame);
573
+ if (refused) throw new Refusal(refused);
574
+ return;
575
+ }
553
576
  case "client_hello":
554
577
  connection.clientTools = frame.tools;
555
578
  return;
@@ -1,4 +1,4 @@
1
- import type { Logger, RouteSocket, Speaker } from "pi-roundtable";
1
+ import type { AskSupport, Logger, RouteSocket, Speaker } from "pi-roundtable";
2
2
  import type { WebIdentity } from "./oidc.ts";
3
3
  import type { ServerFrame } from "./protocol.ts";
4
4
 
@@ -18,6 +18,11 @@ export interface Connection {
18
18
  * never asked to run anything.
19
19
  */
20
20
  clientTools?: readonly string[];
21
+ /**
22
+ * What the client declared with `prompt_features` it can show of a questionnaire; undefined
23
+ * until it declares. Such a connection is never sent a `questionnaire` prompt.
24
+ */
25
+ askSupport?: AskSupport;
21
26
  socket?: RouteSocket<Connection>;
22
27
  /** Asks for a fresh token, then closes the socket when the token expires. */
23
28
  timers: ReturnType<typeof setTimeout>[];
@@ -103,9 +108,20 @@ export class Connections {
103
108
  );
104
109
  }
105
110
 
106
- /** Sends a frame to every connection of a person; none open is not an error. */
107
- sendTo(principal: string, frame: ServerFrame): void {
111
+ /** Sends a frame to every connection of a person, or those `where` accepts; none open is not an error. */
112
+ sendTo(
113
+ principal: string,
114
+ frame: ServerFrame,
115
+ where?: (connection: Connection) => boolean,
116
+ ): void {
108
117
  for (const connection of this.#byPrincipal.get(principal) ?? [])
109
- this.send(connection, frame);
118
+ if (!where || where(connection)) this.send(connection, frame);
119
+ }
120
+
121
+ /** What each open connection of a person declared it can show of a questionnaire; connections that declared nothing are left out. */
122
+ askSupports(principal: string): AskSupport[] {
123
+ return [...(this.#byPrincipal.get(principal) ?? [])].flatMap((c) =>
124
+ c.askSupport ? [c.askSupport] : [],
125
+ );
110
126
  }
111
127
  }
package/src/prompts.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  import {
2
2
  type Approval,
3
3
  type ApprovalDetails,
4
+ type AskSupport,
5
+ checkAnswer,
6
+ checkQuestionnaireAnswer,
4
7
  type OwnerAnswer,
5
8
  type OwnerQuestion,
6
9
  type PromptScope,
7
10
  type Prompts,
11
+ type Questionnaire,
12
+ type QuestionnaireAnswer,
8
13
  TIERS,
9
14
  type Tier,
10
15
  withdrawalNote,
11
16
  } from "pi-roundtable";
17
+ import type { Connection } from "./connections.ts";
12
18
  import {
13
19
  APPROVAL_MAX_BYTES,
20
+ type ClientFrame,
14
21
  type ErrorCode,
15
22
  type PromptFrame,
16
23
  type PromptOutcome,
@@ -29,16 +36,38 @@ interface Open {
29
36
  frame: PromptFrame;
30
37
  /** The lowest tier that may approve; questions have none. */
31
38
  minTier?: Tier;
32
- settle(outcome: PromptOutcome, answer?: OwnerAnswer, note?: string): void;
39
+ /** A questionnaire: the connections it was sent to, which are the only ones told it closed. */
40
+ sentTo?: Set<Connection>;
41
+ /** A questionnaire no connection can show waits this long for one to, then expires. */
42
+ grace?: ReturnType<typeof setTimeout>;
43
+ settle(
44
+ outcome: PromptOutcome,
45
+ answer?: OwnerAnswer | QuestionnaireAnswer,
46
+ note?: string,
47
+ ): void;
33
48
  }
34
49
 
35
50
  export interface PromptDeskOptions {
36
51
  /** Sends a frame to every connection of a person. */
37
52
  send(principal: string, frame: ServerFrame): void;
53
+ /** Sends a frame to the connections of a person that `where` accepts. */
54
+ sendWhere(
55
+ principal: string,
56
+ frame: ServerFrame,
57
+ where: (connection: Connection) => boolean,
58
+ ): void;
59
+ /** What the open connections of a person declared they can show of a questionnaire. */
60
+ supports(principal: string): AskSupport[];
61
+ /** Sends a frame on one connection. */
62
+ sendOne(connection: Connection, frame: ServerFrame): void;
38
63
  /** An unanswered prompt expires after this long. */
39
64
  timeoutMs: number;
65
+ /** How long a questionnaire that no connection can show waits for one (default 60 seconds). */
66
+ graceMs?: number;
40
67
  }
41
68
 
69
+ const GRACE_MS = 60_000;
70
+
42
71
  const atLeast = (tier: Tier, least: Tier) =>
43
72
  TIERS.indexOf(tier) >= TIERS.indexOf(least);
44
73
 
@@ -62,6 +91,7 @@ export class PromptDesk {
62
91
  * a private conversation, so the call stays held, as an unanswered card leaves it.
63
92
  */
64
93
  prompts(conversation: string, scope: PromptScope): Prompts {
94
+ const desk = this;
65
95
  return {
66
96
  confirm: async (
67
97
  title,
@@ -91,7 +121,7 @@ export class PromptDesk {
91
121
  return toApproval(outcome.outcome);
92
122
  },
93
123
  ask: async (title, question, signal) => {
94
- const { outcome, answer } = await this.#ask(
124
+ const { outcome, answer } = await this.#ask<OwnerAnswer>(
95
125
  conversation,
96
126
  scope.principalId,
97
127
  (id) => questionFrame(id, title, question),
@@ -99,13 +129,63 @@ export class PromptDesk {
99
129
  );
100
130
  return outcome === "answered" ? answer : undefined;
101
131
  },
132
+ // The minimum of what the person's declaring panels show; read when asked, so a panel
133
+ // that connects mid-turn counts. None declared means the 0.19 contract.
134
+ get asks() {
135
+ return desk.#asksFor(scope.principalId);
136
+ },
137
+ get questionnaire() {
138
+ const asks = desk.#asksFor(scope.principalId);
139
+ if (!asks || (asks.questions < 2 && !asks.previews)) return undefined;
140
+ return async (
141
+ title: string,
142
+ questionnaire: Questionnaire,
143
+ signal?: AbortSignal,
144
+ ) => {
145
+ const { outcome, answer } = await desk.#ask<QuestionnaireAnswer>(
146
+ conversation,
147
+ scope.principalId,
148
+ (id) => ({
149
+ id,
150
+ kind: "questionnaire",
151
+ title,
152
+ questions: questionnaire.questions.map((q) => ({
153
+ ...q,
154
+ options: q.options.map((option) => ({ ...option })),
155
+ })),
156
+ }),
157
+ signal,
158
+ );
159
+ return outcome === "answered" ? answer : undefined;
160
+ };
161
+ },
102
162
  };
103
163
  }
104
164
 
105
- /** The open prompts of a person, as frames to send again when they connect. */
106
- openFor(principal: string): ServerFrame[] {
165
+ /** The field-wise minimum of the person's declared supports; undefined when none declared. */
166
+ #asksFor(principal: string): AskSupport | undefined {
167
+ const supports = this.#options.supports(principal);
168
+ const [first, ...rest] = supports;
169
+ if (!first) return undefined;
170
+ return rest.reduce<AskSupport>(
171
+ (least, s) => ({
172
+ questions: Math.min(least.questions, s.questions),
173
+ previews: least.previews && s.previews,
174
+ note: least.note && s.note,
175
+ }),
176
+ first,
177
+ );
178
+ }
179
+
180
+ /**
181
+ * The open prompts of a person, as frames to send again when they connect. A questionnaire is
182
+ * left out unless the connection declared `support` that covers it.
183
+ */
184
+ openFor(principal: string, support?: AskSupport): ServerFrame[] {
107
185
  return [...this.#open.values()]
108
- .filter((open) => open.principal === principal)
186
+ .filter(
187
+ (open) => open.principal === principal && showable(open.frame, support),
188
+ )
109
189
  .map((open) => ({
110
190
  type: "prompt",
111
191
  conversation: open.conversation,
@@ -113,6 +193,34 @@ export class PromptDesk {
113
193
  }));
114
194
  }
115
195
 
196
+ /**
197
+ * A connection of the person closed. A questionnaire that no remaining connection can show
198
+ * would leave the turn waiting for an answer no one can give, so it gets a grace period for a
199
+ * panel to reconnect and declare, then expires and the model is told it went unanswered.
200
+ */
201
+ closed(principal: string): void {
202
+ this.#regrace(principal);
203
+ }
204
+
205
+ /** Starts or cancels the grace timer of each open questionnaire of a person. */
206
+ #regrace(principal: string): void {
207
+ const supports = this.#options.supports(principal);
208
+ for (const open of this.#open.values()) {
209
+ if (open.principal !== principal || open.frame.kind !== "questionnaire")
210
+ continue;
211
+ const frame = open.frame;
212
+ if (supports.some((s) => showable(frame, s))) {
213
+ clearTimeout(open.grace);
214
+ open.grace = undefined;
215
+ } else if (!open.grace) {
216
+ open.grace = setTimeout(
217
+ () => open.settle("expired"),
218
+ this.#options.graceMs ?? GRACE_MS,
219
+ );
220
+ }
221
+ }
222
+ }
223
+
116
224
  /** Approves or declines; an error code when the prompt is not an approval this person may answer. */
117
225
  approve(
118
226
  from: Answerer,
@@ -136,12 +244,74 @@ export class PromptDesk {
136
244
  const open = this.#open.get(prompt);
137
245
  if (open?.frame.kind !== "ask") return "unknown_prompt";
138
246
  if (open.principal !== from.principalId) return "forbidden";
139
- if (!fits(open.frame, answer)) return "bad_frame";
247
+ if (!checkAnswer(open.frame, answer)) return "bad_frame";
248
+ open.settle("answered", answer);
249
+ return undefined;
250
+ }
251
+
252
+ /**
253
+ * Takes the frames that concern questionnaires: `prompt_features` declares what the connection
254
+ * shows and sends it the open questionnaires it can now show, and `answers` answers one.
255
+ * An error code when the frame is refused.
256
+ */
257
+ receive(
258
+ connection: Connection,
259
+ from: Answerer,
260
+ frame: Extract<ClientFrame, { type: "prompt_features" | "answers" }>,
261
+ ): ErrorCode | undefined {
262
+ if (frame.type === "answers") return this.#answers(from, frame);
263
+ const before = connection.askSupport;
264
+ connection.askSupport = frame.ask;
265
+ const changed =
266
+ !before ||
267
+ before.questions !== frame.ask.questions ||
268
+ before.previews !== frame.ask.previews ||
269
+ before.note !== frame.ask.note;
270
+ // A repeated declaration (each `ready` asks for one) does not send what is already shown.
271
+ if (changed)
272
+ for (const open of this.#open.values())
273
+ if (
274
+ open.principal === from.principalId &&
275
+ open.sentTo &&
276
+ showable(open.frame, frame.ask) &&
277
+ !(open.sentTo.has(connection) && showable(open.frame, before))
278
+ ) {
279
+ open.sentTo.add(connection);
280
+ this.#options.sendOne(connection, {
281
+ type: "prompt",
282
+ conversation: open.conversation,
283
+ prompt: open.frame,
284
+ });
285
+ }
286
+ this.#regrace(from.principalId);
287
+ return undefined;
288
+ }
289
+
290
+ #answers(
291
+ from: Answerer,
292
+ frame: Extract<ClientFrame, { type: "answers" }>,
293
+ ): ErrorCode | undefined {
294
+ const open = this.#open.get(frame.prompt);
295
+ if (open?.frame.kind !== "questionnaire") return "unknown_prompt";
296
+ if (open.principal !== from.principalId) return "forbidden";
297
+ const answer: QuestionnaireAnswer = {
298
+ answers: frame.answers.map((a) =>
299
+ a === null || !(a.choices.length > 0 || a.text?.trim())
300
+ ? undefined
301
+ : {
302
+ choices: a.choices,
303
+ ...(a.text?.trim() ? { text: a.text.trim() } : {}),
304
+ },
305
+ ),
306
+ ...(frame.note?.trim() ? { note: frame.note.trim() } : {}),
307
+ };
308
+ if (!checkQuestionnaireAnswer({ questions: open.frame.questions }, answer))
309
+ return "bad_frame";
140
310
  open.settle("answered", answer);
141
311
  return undefined;
142
312
  }
143
313
 
144
- #ask(
314
+ #ask<A extends OwnerAnswer | QuestionnaireAnswer = OwnerAnswer>(
145
315
  conversation: string,
146
316
  principal: string,
147
317
  frameOf: (id: string) => PromptFrame,
@@ -149,13 +319,22 @@ export class PromptDesk {
149
319
  minTier?: Tier,
150
320
  supersede?: AbortSignal,
151
321
  approvedPrompt?: (prompt: string) => void,
152
- ): Promise<{ outcome: PromptOutcome; answer?: OwnerAnswer }> {
322
+ ): Promise<{ outcome: PromptOutcome; answer?: A }> {
153
323
  const { send, timeoutMs } = this.#options;
154
324
  if (signal?.aborted || supersede?.aborted)
155
325
  return Promise.resolve({ outcome: "cancelled" });
156
326
  return new Promise((resolve) => {
157
327
  const id = crypto.randomUUID();
158
328
  const frame = frameOf(id);
329
+ // A questionnaire reaches only the connections that declared support covering it.
330
+ const sentTo = new Set<Connection>();
331
+ const sendQuestionnaire = (out: ServerFrame) =>
332
+ this.#options.sendWhere(principal, out, (connection) => {
333
+ if (out.type === "prompt_closed") return sentTo.has(connection);
334
+ const shows = showable(frame, connection.askSupport);
335
+ if (shows) sentTo.add(connection);
336
+ return shows;
337
+ });
159
338
  const onAbort = () => settle("cancelled");
160
339
  const timer = setTimeout(() => settle("expired"), timeoutMs);
161
340
  const onSupersede = () =>
@@ -163,35 +342,50 @@ export class PromptDesk {
163
342
  settle("withdrawn", undefined, withdrawalNote(supersede, ""));
164
343
  const settle = (
165
344
  outcome: PromptOutcome,
166
- answer?: OwnerAnswer,
345
+ answer?: OwnerAnswer | QuestionnaireAnswer,
167
346
  note?: string,
168
347
  ) => {
169
348
  // Whichever of an answer and a withdrawal comes first settles the prompt.
170
- if (!this.#open.delete(id)) return;
349
+ const open = this.#open.get(id);
350
+ if (!open || !this.#open.delete(id)) return;
351
+ clearTimeout(open.grace);
171
352
  // Heard before the approval resolves, so the call that runs on it can say which card it was.
172
353
  if (outcome === "approved") approvedPrompt?.(id);
173
354
  clearTimeout(timer);
174
355
  signal?.removeEventListener("abort", onAbort);
175
356
  supersede?.removeEventListener("abort", onSupersede);
176
- send(principal, {
357
+ const closed: ServerFrame = {
177
358
  type: "prompt_closed",
178
359
  conversation,
179
360
  prompt: id,
180
361
  outcome,
181
362
  ...(note ? { note } : {}),
182
- });
183
- resolve(answer ? { outcome, answer } : { outcome });
363
+ };
364
+ if (frame.kind === "questionnaire") sendQuestionnaire(closed);
365
+ else send(principal, closed);
366
+ resolve(answer ? { outcome, answer: answer as A } : { outcome });
184
367
  };
185
368
  this.#open.set(id, {
186
369
  conversation,
187
370
  principal,
188
371
  frame,
189
372
  ...(minTier ? { minTier } : {}),
373
+ ...(frame.kind === "questionnaire" ? { sentTo } : {}),
190
374
  settle,
191
375
  });
192
376
  signal?.addEventListener("abort", onAbort, { once: true });
193
377
  supersede?.addEventListener("abort", onSupersede, { once: true });
194
- send(principal, { type: "prompt", conversation, prompt: frame });
378
+ const shown: ServerFrame = {
379
+ type: "prompt",
380
+ conversation,
381
+ prompt: frame,
382
+ };
383
+ if (frame.kind === "questionnaire") {
384
+ sendQuestionnaire(shown);
385
+ // Sent to no one: wait the grace for a panel that can show it, never the full timeout.
386
+ // With no tab connected at all this waits too, so a reconnect can still rescue it.
387
+ this.#regrace(principal);
388
+ } else send(principal, shown);
195
389
  });
196
390
  }
197
391
  }
@@ -308,17 +502,19 @@ function questionFrame(
308
502
  };
309
503
  }
310
504
 
311
- /** Whether an answer is one the question allows: offered labels, one unless multi, own text only where allowed. */
312
- function fits(
313
- frame: Extract<PromptFrame, { kind: "ask" }>,
314
- answer: OwnerAnswer,
505
+ /**
506
+ * Whether a connection with `support` may be sent the prompt: every prompt but a questionnaire,
507
+ * which only a connection that declared support covering its questions and previews may have.
508
+ */
509
+ function showable(
510
+ frame: PromptFrame,
511
+ support: AskSupport | undefined,
315
512
  ): boolean {
316
- const labels = frame.options.map((option) => option.label);
317
- const text = answer.text?.trim();
318
- if (!answer.choices.every((choice) => labels.includes(choice))) return false;
319
- if (new Set(answer.choices).size !== answer.choices.length) return false;
320
- if (!frame.multi && answer.choices.length > 1) return false;
321
- if (labels.length === 0) return answer.choices.length === 0 && Boolean(text);
322
- if (text && !frame.allowOther) return false;
323
- return answer.choices.length > 0 || Boolean(text);
513
+ if (frame.kind !== "questionnaire") return true;
514
+ if (!support) return false;
515
+ if (frame.questions.length > support.questions) return false;
516
+ const previews = frame.questions.some((q) =>
517
+ q.options.some((o) => o.preview),
518
+ );
519
+ return support.previews || !previews;
324
520
  }
package/src/protocol.ts CHANGED
@@ -1,8 +1,12 @@
1
- import type {
2
- ApprovalDetails,
3
- AskOption,
4
- Tier,
5
- TurnProgress,
1
+ import {
2
+ type ApprovalDetails,
3
+ type AskOption,
4
+ type AskSupport,
5
+ type OwnerAnswer,
6
+ type OwnerQuestion,
7
+ QUESTIONNAIRE_LIMITS,
8
+ type Tier,
9
+ type TurnProgress,
6
10
  } from "pi-roundtable";
7
11
  import type { Notice } from "./notices.ts";
8
12
 
@@ -70,8 +74,22 @@ export type PromptFrame =
70
74
  options: readonly AskOption[];
71
75
  multi: boolean;
72
76
  allowOther: boolean;
77
+ }
78
+ | {
79
+ id: string;
80
+ kind: "questionnaire";
81
+ title: string;
82
+ /** The core's questions, verbatim; sent only to connections that declared `prompt_features` covering them. */
83
+ questions: readonly OwnerQuestion[];
73
84
  };
74
85
 
86
+ /** What a server that takes `prompt_features` advertises in `ready.prompts` and can send. */
87
+ export const ADVERTISED_ASK: AskSupport = Object.freeze({
88
+ questions: QUESTIONNAIRE_LIMITS.questions,
89
+ previews: true,
90
+ note: true,
91
+ });
92
+
75
93
  /**
76
94
  * How a prompt closed. `withdrawn` is an approval the plugin that asked for it took back; the
77
95
  * frame may carry a short `note` saying why.
@@ -163,6 +181,21 @@ export type ClientFrame =
163
181
  | { type: "approval"; prompt: string; approved: boolean }
164
182
  /** Answers a question prompt: the chosen options' labels and, where allowed, their own text. */
165
183
  | { type: "answer"; prompt: string; choices: string[]; text?: string }
184
+ /**
185
+ * Answers a `questionnaire` prompt: one entry per question in order, `null` for a question left
186
+ * blank, and an optional note on the whole set.
187
+ */
188
+ | {
189
+ type: "answers";
190
+ prompt: string;
191
+ answers: (OwnerAnswer | null)[];
192
+ note?: string;
193
+ }
194
+ /**
195
+ * Declares what this connection can show of a questionnaire. Send it only after a `ready` that
196
+ * has `prompts`; a connection that never declares is never sent a `questionnaire` prompt.
197
+ */
198
+ | { type: "prompt_features"; ask: AskSupport }
166
199
  /**
167
200
  * Declares the functions this connection can run for the assistant's tools, such as reading the
168
201
  * page the person has open. Send it after each `ready`. A connection that declares none is never
@@ -214,6 +247,11 @@ export type ServerFrame =
214
247
  * `client_call`: show no feature that needs the page's help then.
215
248
  */
216
249
  clientCalls?: ClientCallsAdvertised;
250
+ /**
251
+ * What questionnaires this server can send. Absent from a server before 0.20: never send
252
+ * `prompt_features` then. A client that sees it may declare what it shows.
253
+ */
254
+ prompts?: { ask: AskSupport };
217
255
  }
218
256
  /** The message `id` was taken into `conversation`, a new one when the client named none. */
219
257
  | { type: "accepted"; id: string; conversation: string }
@@ -354,6 +392,10 @@ export function parseClientFrame(
354
392
  ...(text === undefined ? {} : { text }),
355
393
  };
356
394
  }
395
+ case "answers":
396
+ return parseAnswers(value);
397
+ case "prompt_features":
398
+ return parseFeatures(value);
357
399
  case "client_hello":
358
400
  return parseHello(value);
359
401
  case "client_result":
@@ -363,6 +405,56 @@ export function parseClientFrame(
363
405
  }
364
406
  }
365
407
 
408
+ function parseAnswers(value: Record<string, unknown>): ClientFrame | undefined {
409
+ const { prompt, answers, note } = value;
410
+ if (!isId(prompt) || !Array.isArray(answers)) return undefined;
411
+ if (answers.length < 1 || answers.length > QUESTIONNAIRE_LIMITS.questions)
412
+ return undefined;
413
+ if (!optional(note, (n): n is string => typeof n === "string"))
414
+ return undefined;
415
+ const parsed: (OwnerAnswer | null)[] = [];
416
+ for (const answer of answers as unknown[]) {
417
+ if (answer === null) {
418
+ parsed.push(null);
419
+ continue;
420
+ }
421
+ if (!isRecord(answer) || !Array.isArray(answer.choices)) return undefined;
422
+ const { choices, text } = answer;
423
+ if (!choices.every((choice) => typeof choice === "string"))
424
+ return undefined;
425
+ if (!optional(text, (t): t is string => typeof t === "string"))
426
+ return undefined;
427
+ parsed.push({
428
+ choices: choices as string[],
429
+ ...(text === undefined ? {} : { text }),
430
+ });
431
+ }
432
+ return {
433
+ type: "answers",
434
+ prompt,
435
+ answers: parsed,
436
+ ...(note === undefined ? {} : { note }),
437
+ };
438
+ }
439
+
440
+ function parseFeatures(
441
+ value: Record<string, unknown>,
442
+ ): ClientFrame | undefined {
443
+ const { ask } = value;
444
+ if (!isRecord(ask)) return undefined;
445
+ const { questions, previews, note } = ask;
446
+ if (
447
+ typeof questions !== "number" ||
448
+ !Number.isInteger(questions) ||
449
+ questions < 1 ||
450
+ questions > QUESTIONNAIRE_LIMITS.questions ||
451
+ typeof previews !== "boolean" ||
452
+ typeof note !== "boolean"
453
+ )
454
+ return undefined;
455
+ return { type: "prompt_features", ask: { questions, previews, note } };
456
+ }
457
+
366
458
  function parseHello(value: Record<string, unknown>): ClientFrame | undefined {
367
459
  const { version, tools } = value;
368
460
  if (version !== 1 || !Array.isArray(tools)) return undefined;