Archived
feat(sessions): add the pending ask store
Own the one-open-ask-per-session lifecycle in daemon-side domain logic: validate model-authored question sets, validate submitted answers against them, and compute the answered-versus-unanswered outcome both the model-facing follow-up message and the browser record are rendered from. Also lands the answer half of the shared ask contract alongside its first consumer.
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ASK_USER_OPTION_LIMIT, ASK_USER_QUESTION_LIMIT, type AskUserQuestion } from "../../shared/apiTypes.js";
|
||||
import {
|
||||
PendingAskStore,
|
||||
PendingAskValidationError,
|
||||
renderAskUserAnswersText,
|
||||
renderSupersededAskText,
|
||||
} from "./pendingAskStore.js";
|
||||
|
||||
const sessionId = "session-1";
|
||||
|
||||
function testStore() {
|
||||
let askCount = 0;
|
||||
let tick = 0;
|
||||
return new PendingAskStore({
|
||||
createAskId: () => `ask-${(++askCount).toString()}`,
|
||||
now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++)),
|
||||
});
|
||||
}
|
||||
|
||||
function question(id: string, overrides: Partial<AskUserQuestion> = {}): AskUserQuestion {
|
||||
return {
|
||||
id,
|
||||
question: `Question ${id}?`,
|
||||
options: [
|
||||
{ value: "yes", label: "Yes" },
|
||||
{ value: "no", label: "No" },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function openTwoQuestions(store: PendingAskStore) {
|
||||
return store.open({ sessionId, questions: [question("q1"), question("q2")] });
|
||||
}
|
||||
|
||||
describe("PendingAskStore validation", () => {
|
||||
it("normalizes an accepted ask and reports it as the session's pending ask", () => {
|
||||
const store = testStore();
|
||||
const result = store.open({
|
||||
sessionId,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
question: "Which database?",
|
||||
detail: "Only the primary store matters here.",
|
||||
options: [{ value: "pg", label: "Postgres", detail: "Existing cluster" }],
|
||||
allowOther: true,
|
||||
multiple: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.ask).toEqual({
|
||||
askId: "ask-1",
|
||||
askedAt: "2026-01-01T00:00:00.000Z",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
question: "Which database?",
|
||||
detail: "Only the primary store matters here.",
|
||||
options: [{ value: "pg", label: "Postgres", detail: "Existing cluster" }],
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.superseded).toBeUndefined();
|
||||
expect(store.pendingAsk(sessionId)).toEqual(result.ask);
|
||||
expect(store.pendingAsk("other-session")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects asks the user could not meaningfully answer", () => {
|
||||
const store = testStore();
|
||||
const reject = (questions: AskUserQuestion[]) => () => store.open({ sessionId, questions });
|
||||
|
||||
expect(reject([])).toThrow(PendingAskValidationError);
|
||||
expect(reject(Array.from({ length: ASK_USER_QUESTION_LIMIT + 1 }, (_, index) => question(`q${index.toString()}`))))
|
||||
.toThrow(/more than 20 questions/);
|
||||
expect(reject([question("q1"), question("q1")])).toThrow(/Duplicate question id q1/);
|
||||
expect(reject([question(" ")])).toThrow(/question id must not be empty/);
|
||||
expect(reject([question("q1", { question: " " })])).toThrow(/text of question q1 must not be empty/);
|
||||
expect(reject([question("q1", { options: [] })])).toThrow(/must offer options or allow other text/);
|
||||
expect(reject([question("q1", { options: [{ value: "a", label: "A" }, { value: "a", label: "Again" }] })]))
|
||||
.toThrow(/Duplicate option value a in question q1/);
|
||||
expect(reject([question("q1", { options: [{ value: "a", label: " " }] })]))
|
||||
.toThrow(/label of option a in question q1 must not be empty/);
|
||||
expect(reject([question("q1", {
|
||||
options: Array.from({ length: ASK_USER_OPTION_LIMIT + 1 }, (_, index) => ({ value: `v${index.toString()}`, label: "L" })),
|
||||
})])).toThrow(/more than 12 options/);
|
||||
expect(store.pendingAsk(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a question that only offers free text", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [], allowOther: true })] });
|
||||
expect(ask.questions[0]).toEqual({ id: "q1", question: "Question q1?", options: [], allowOther: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PendingAskStore submit", () => {
|
||||
it("reports answered and unanswered questions for a partial submit", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({
|
||||
sessionId,
|
||||
questions: [question("q1"), question("q2"), question("q3")],
|
||||
});
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q2", values: ["no"] }] });
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "closed",
|
||||
outcome: {
|
||||
askId: "ask-1",
|
||||
reason: "submitted",
|
||||
askedAt: "2026-01-01T00:00:00.000Z",
|
||||
closedAt: "2026-01-01T00:00:01.000Z",
|
||||
questions: [
|
||||
{ question: ask.questions[0], answered: false, values: [] },
|
||||
{ question: ask.questions[1], answered: true, values: ["no"] },
|
||||
{ question: ask.questions[2], answered: false, values: [] },
|
||||
],
|
||||
answeredCount: 1,
|
||||
unansweredIds: ["q1", "q3"],
|
||||
summary: "Answered 1 of 3; unanswered: q1, q3",
|
||||
},
|
||||
});
|
||||
expect(store.pendingAsk(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summarizes a fully answered ask without an unanswered list", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: ["yes"] }, { id: "q2", values: ["no"] }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "closed",
|
||||
outcome: { answeredCount: 2, unansweredIds: [], summary: "Answered 2 of 2; none left unanswered" },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty answer as leaving the question untouched", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({ sessionId, questions: [question("q1"), question("q2", { allowOther: true })] });
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: [] }, { id: "q2", values: [], otherText: " " }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: "closed", outcome: { answeredCount: 0, unansweredIds: ["q1", "q2"] } });
|
||||
});
|
||||
|
||||
it("rejects several values for a single-select question and keeps the ask open", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
expect(() => store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: ["yes", "no"] }] }))
|
||||
.toThrow(/Question q1 accepts a single answer/);
|
||||
expect(store.pendingAsk(sessionId)?.askId).toBe(ask.askId);
|
||||
});
|
||||
|
||||
it("accepts several values and coexisting other text for a multi-select question", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({
|
||||
sessionId,
|
||||
questions: [question("q1", { multiple: true, allowOther: true })],
|
||||
});
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: ["yes", "no"], otherText: " maybe later " }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "closed",
|
||||
outcome: {
|
||||
questions: [{ answered: true, values: ["yes", "no"], otherText: "maybe later" }],
|
||||
answeredCount: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects other text for a question that does not allow it", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
expect(() => store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "custom" }] }))
|
||||
.toThrow(/Question q1 does not accept other text/);
|
||||
});
|
||||
|
||||
it("answers a free-text-only question with other text alone", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [], allowOther: true })] });
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "a note" }] });
|
||||
|
||||
expect(result).toMatchObject({ status: "closed", outcome: { questions: [{ answered: true, values: [], otherText: "a note" }] } });
|
||||
});
|
||||
|
||||
it("rejects unknown, duplicated, and unoffered answers", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
const reject = (answers: Parameters<typeof store.submit>[2]["answers"]) => () => store.submit(sessionId, ask.askId, { answers });
|
||||
|
||||
expect(reject([{ id: "q9", values: ["yes"] }])).toThrow(/Unknown question id q9/);
|
||||
expect(reject([{ id: "q1", values: ["yes"] }, { id: "q1", values: ["no"] }])).toThrow(/Duplicate answer for question q1/);
|
||||
expect(reject([{ id: "q1", values: ["nope"] }])).toThrow(/Question q1 has no option nope/);
|
||||
expect(reject([{ id: "q1", values: ["yes", "yes"] }])).toThrow(/Duplicate value yes for question q1/);
|
||||
expect(store.pendingAsk(sessionId)?.askId).toBe(ask.askId);
|
||||
});
|
||||
|
||||
it("treats a submit or cancel for an ask that is no longer open as stale", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
expect(store.submit(sessionId, "ask-other", { answers: [] })).toEqual({ status: "stale" });
|
||||
expect(store.cancel(sessionId, "ask-other")).toEqual({ status: "stale" });
|
||||
expect(store.submit("session-2", ask.askId, { answers: [] })).toEqual({ status: "stale" });
|
||||
|
||||
store.submit(sessionId, ask.askId, { answers: [] });
|
||||
expect(store.submit(sessionId, ask.askId, { answers: [] })).toEqual({ status: "stale" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PendingAskStore close transitions", () => {
|
||||
it("supersedes an unanswered ask and reports its questions as unanswered", () => {
|
||||
const store = testStore();
|
||||
const first = openTwoQuestions(store);
|
||||
|
||||
const second = store.open({ sessionId, questions: [question("q3")] });
|
||||
|
||||
expect(second.ask.askId).toBe("ask-2");
|
||||
expect(second.superseded).toEqual({
|
||||
askId: "ask-1",
|
||||
reason: "superseded",
|
||||
askedAt: "2026-01-01T00:00:00.000Z",
|
||||
closedAt: "2026-01-01T00:00:01.000Z",
|
||||
questions: [
|
||||
{ question: first.ask.questions[0], answered: false, values: [] },
|
||||
{ question: first.ask.questions[1], answered: false, values: [] },
|
||||
],
|
||||
answeredCount: 0,
|
||||
unansweredIds: ["q1", "q2"],
|
||||
summary: "Answered 0 of 2; unanswered: q1, q2",
|
||||
});
|
||||
expect(store.pendingAsk(sessionId)?.askId).toBe("ask-2");
|
||||
expect(store.submit(sessionId, first.ask.askId, { answers: [] })).toEqual({ status: "stale" });
|
||||
});
|
||||
|
||||
it("does not supersede an ask that belongs to another session", () => {
|
||||
const store = testStore();
|
||||
const first = openTwoQuestions(store);
|
||||
|
||||
const second = store.open({ sessionId: "session-2", questions: [question("q3")] });
|
||||
|
||||
expect(second.superseded).toBeUndefined();
|
||||
expect(store.pendingAsk(sessionId)?.askId).toBe(first.ask.askId);
|
||||
});
|
||||
|
||||
it("cancels an open ask as fully unanswered", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
expect(store.cancel(sessionId, ask.askId)).toMatchObject({
|
||||
status: "closed",
|
||||
outcome: { reason: "cancelled", answeredCount: 0, unansweredIds: ["q1", "q2"] },
|
||||
});
|
||||
expect(store.pendingAsk(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forgets the open ask of a session that goes away without reporting an outcome", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
store.forgetSession(sessionId);
|
||||
|
||||
expect(store.pendingAsk(sessionId)).toBeUndefined();
|
||||
expect(store.cancel(sessionId, ask.askId)).toEqual({ status: "stale" });
|
||||
});
|
||||
|
||||
it("keeps each session's open ask separate", () => {
|
||||
const store = testStore();
|
||||
const first = openTwoQuestions(store);
|
||||
const second = store.open({ sessionId: "session-2", questions: [question("q3")] });
|
||||
|
||||
expect([store.pendingAsk(sessionId)?.askId, store.pendingAsk("session-2")?.askId])
|
||||
.toEqual([first.ask.askId, second.ask.askId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ask outcome rendering", () => {
|
||||
it("names answered and unanswered questions in the model-facing text", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({
|
||||
sessionId,
|
||||
questions: [question("q1"), question("q2", { allowOther: true }), question("q3")],
|
||||
});
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: ["yes"] }, { id: "q2", values: [], otherText: "something else" }],
|
||||
});
|
||||
if (result.status !== "closed") throw new Error("expected the ask to close");
|
||||
|
||||
expect(renderAskUserAnswersText(result.outcome)).toBe([
|
||||
"The user submitted answers to your questions.",
|
||||
"",
|
||||
"- q1: Question q1?",
|
||||
" Answered: selected yes",
|
||||
"- q2: Question q2?",
|
||||
` Answered: other: "something else"`,
|
||||
"- q3: Question q3?",
|
||||
" Unanswered.",
|
||||
"",
|
||||
"Answered 2 of 3; unanswered: q3",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it("tells the model a cancelled ask was closed before it was answered", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
const result = store.cancel(sessionId, ask.askId);
|
||||
if (result.status !== "closed") throw new Error("expected the ask to close");
|
||||
|
||||
expect(renderAskUserAnswersText(result.outcome)).toContain("closed (cancelled) before it was fully answered");
|
||||
});
|
||||
|
||||
it("names the abandoned questions when an ask is superseded", () => {
|
||||
const store = testStore();
|
||||
openTwoQuestions(store);
|
||||
const superseded = store.open({ sessionId, questions: [question("q3")] }).superseded;
|
||||
if (superseded === undefined) throw new Error("expected a superseded outcome");
|
||||
|
||||
expect(renderSupersededAskText(superseded)).toBe([
|
||||
"This replaced an earlier question set (ask-1) that the user never submitted.",
|
||||
"Left unanswered: q1, q2.",
|
||||
].join("\n"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
ASK_USER_ID_MAX_LENGTH,
|
||||
ASK_USER_OPTION_LIMIT,
|
||||
ASK_USER_OTHER_TEXT_MAX_LENGTH,
|
||||
ASK_USER_QUESTION_LIMIT,
|
||||
ASK_USER_TEXT_MAX_LENGTH,
|
||||
type AskUserAnswer,
|
||||
type AskUserCloseReason,
|
||||
type AskUserOutcome,
|
||||
type AskUserQuestion,
|
||||
type AskUserQuestionOption,
|
||||
type AskUserQuestionRecord,
|
||||
type AskUserSubmission,
|
||||
type PendingAskUser,
|
||||
} from "../../shared/apiTypes.js";
|
||||
|
||||
export interface PendingAskStoreOptions {
|
||||
now?: (() => Date) | undefined;
|
||||
createAskId?: (() => string) | undefined;
|
||||
}
|
||||
|
||||
/** A question set an agent wants to post to the user of one session. */
|
||||
export interface PendingAskOpenInput {
|
||||
sessionId: string;
|
||||
questions: AskUserQuestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A freshly opened ask, plus the outcome of the ask it replaced. A session holds
|
||||
* at most one open ask, so opening while one is still unanswered supersedes it —
|
||||
* and the caller must report that outcome to the model, naming the questions the
|
||||
* user never got to answer.
|
||||
*/
|
||||
export interface PendingAskOpenResult {
|
||||
ask: PendingAskUser;
|
||||
superseded?: AskUserOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of submitting or cancelling an ask. `"stale"` means the ask named by the
|
||||
* caller is no longer the session's open ask (already submitted, superseded, or
|
||||
* gone with its daemon-side session), which is an ordinary race a browser can
|
||||
* lose — not an error.
|
||||
*/
|
||||
export type PendingAskCloseResult =
|
||||
| { status: "closed"; outcome: AskUserOutcome }
|
||||
| { status: "stale" };
|
||||
|
||||
/** Rejected input: the model asked something unanswerable, or an answer does not fit its question. */
|
||||
export class PendingAskValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PendingAskValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
type RecordedAnswers = ReadonlyMap<string, AskUserAnswer>;
|
||||
|
||||
/**
|
||||
* Daemon-owned open-ask state: one unanswered question set per session.
|
||||
*
|
||||
* The store is pure domain logic — no Fastify, no Pi session, no I/O, no timers.
|
||||
* It validates asks and answers, owns the open/supersede/submit/cancel
|
||||
* transitions, and computes the answered-versus-unanswered outcome that both the
|
||||
* model-facing message and the browser record are rendered from. Callers publish
|
||||
* the returned asks and outcomes; the store never emits anything itself.
|
||||
*
|
||||
* State is deliberately daemon-lifetime and in-memory. An open ask is meaningful
|
||||
* only while the session runtime that posted it exists, and browsers rehydrate it
|
||||
* from `SessionStatus` rather than from disk.
|
||||
*/
|
||||
export class PendingAskStore {
|
||||
private readonly now: () => Date;
|
||||
private readonly createAskId: () => string;
|
||||
private readonly openBySessionId = new Map<string, PendingAskUser>();
|
||||
|
||||
constructor(options: PendingAskStoreOptions = {}) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.createAskId = options.createAskId ?? randomUUID;
|
||||
}
|
||||
|
||||
/** The session's open ask, for {@link SessionStatus} projection. */
|
||||
pendingAsk(sessionId: string): PendingAskUser | undefined {
|
||||
const ask = this.openBySessionId.get(requireSessionId(sessionId));
|
||||
return ask === undefined ? undefined : cloneAsk(ask);
|
||||
}
|
||||
|
||||
open(input: PendingAskOpenInput): PendingAskOpenResult {
|
||||
const sessionId = requireSessionId(input.sessionId);
|
||||
const questions = validateQuestions(input.questions);
|
||||
const askedAt = this.timestamp();
|
||||
const superseded = this.close(sessionId, "superseded", askedAt, new Map());
|
||||
const ask: PendingAskUser = {
|
||||
askId: requireId(this.createAskId(), "askId"),
|
||||
askedAt,
|
||||
questions,
|
||||
};
|
||||
this.openBySessionId.set(sessionId, ask);
|
||||
return {
|
||||
ask: cloneAsk(ask),
|
||||
...(superseded === undefined ? {} : { superseded }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the user replied and close the ask. Answers are validated against
|
||||
* the open ask, so a submission that does not fit its questions is rejected
|
||||
* rather than silently truncated; the ask stays open in that case.
|
||||
*/
|
||||
submit(sessionId: string, askId: string, submission: AskUserSubmission): PendingAskCloseResult {
|
||||
const ask = this.openBySessionId.get(requireSessionId(sessionId));
|
||||
if (ask?.askId !== askId) return { status: "stale" };
|
||||
// Validate before closing so a submission that does not fit its questions
|
||||
// leaves the ask open for the browser to correct.
|
||||
const answers = validateSubmission(ask, submission);
|
||||
return { status: "closed", outcome: this.requireClose(sessionId, "submitted", answers) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the ask without a submission. The outcome reports every question as
|
||||
* unanswered, because answers only ever reach the daemon through a submit.
|
||||
*/
|
||||
cancel(sessionId: string, askId: string): PendingAskCloseResult {
|
||||
const ask = this.openBySessionId.get(requireSessionId(sessionId));
|
||||
if (ask?.askId !== askId) return { status: "stale" };
|
||||
return { status: "closed", outcome: this.requireClose(sessionId, "cancelled", new Map()) };
|
||||
}
|
||||
|
||||
/** Drop the open ask of a session that is going away, without reporting an outcome. */
|
||||
forgetSession(sessionId: string): void {
|
||||
this.openBySessionId.delete(requireSessionId(sessionId));
|
||||
}
|
||||
|
||||
private requireClose(sessionId: string, reason: AskUserCloseReason, answers: RecordedAnswers): AskUserOutcome {
|
||||
const outcome = this.close(sessionId, reason, this.timestamp(), answers);
|
||||
if (outcome === undefined) throw new Error(`Pending ask of session ${sessionId} disappeared while closing`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private close(
|
||||
sessionId: string,
|
||||
reason: AskUserCloseReason,
|
||||
closedAt: string,
|
||||
answers: RecordedAnswers,
|
||||
): AskUserOutcome | undefined {
|
||||
const ask = this.openBySessionId.get(sessionId);
|
||||
if (ask === undefined) return undefined;
|
||||
this.openBySessionId.delete(sessionId);
|
||||
return askUserOutcome(ask, answers, reason, closedAt);
|
||||
}
|
||||
|
||||
private timestamp(): string {
|
||||
return this.now().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-facing text of a closed ask. The model reads this, so it must name the
|
||||
* unanswered questions as plainly as the answered ones.
|
||||
*/
|
||||
export function renderAskUserAnswersText(outcome: AskUserOutcome): string {
|
||||
const lead = outcome.reason === "submitted"
|
||||
? "The user submitted answers to your questions."
|
||||
: `The question set was closed (${outcome.reason}) before it was fully answered.`;
|
||||
return [lead, "", ...outcome.questions.map(questionLines).flat(), "", outcome.summary].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Notice for the model when a new ask replaced one the user never answered, so a
|
||||
* supersede is never a silent loss of the earlier questions.
|
||||
*/
|
||||
export function renderSupersededAskText(outcome: AskUserOutcome): string {
|
||||
return [
|
||||
`This replaced an earlier question set (${outcome.askId}) that the user never submitted.`,
|
||||
`Left unanswered: ${outcome.unansweredIds.join(", ")}.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function questionLines(record: AskUserQuestionRecord): string[] {
|
||||
const header = `- ${record.question.id}: ${record.question.question}`;
|
||||
if (!record.answered) return [header, " Unanswered."];
|
||||
const parts = [...record.values.map((value) => `selected ${value}`)];
|
||||
if (record.otherText !== undefined) parts.push(`other: ${JSON.stringify(record.otherText)}`);
|
||||
return [header, ` Answered: ${parts.join("; ")}`];
|
||||
}
|
||||
|
||||
function askUserOutcome(
|
||||
ask: PendingAskUser,
|
||||
answers: RecordedAnswers,
|
||||
reason: AskUserCloseReason,
|
||||
closedAt: string,
|
||||
): AskUserOutcome {
|
||||
const questions = ask.questions.map((question) => questionRecord(question, answers.get(question.id)));
|
||||
const unansweredIds = questions.filter((record) => !record.answered).map((record) => record.question.id);
|
||||
const answeredCount = questions.length - unansweredIds.length;
|
||||
return {
|
||||
askId: ask.askId,
|
||||
reason,
|
||||
askedAt: ask.askedAt,
|
||||
closedAt,
|
||||
questions,
|
||||
answeredCount,
|
||||
unansweredIds,
|
||||
summary: summaryLine(questions.length, answeredCount, unansweredIds),
|
||||
};
|
||||
}
|
||||
|
||||
function questionRecord(question: AskUserQuestion, answer: AskUserAnswer | undefined): AskUserQuestionRecord {
|
||||
const values = answer?.values ?? [];
|
||||
const otherText = answer?.otherText;
|
||||
return {
|
||||
question: cloneQuestion(question),
|
||||
answered: values.length > 0 || otherText !== undefined,
|
||||
values: [...values],
|
||||
...(otherText === undefined ? {} : { otherText }),
|
||||
};
|
||||
}
|
||||
|
||||
function summaryLine(total: number, answeredCount: number, unansweredIds: string[]): string {
|
||||
const answered = `Answered ${answeredCount.toString()} of ${total.toString()}`;
|
||||
return unansweredIds.length === 0 ? `${answered}; none left unanswered` : `${answered}; unanswered: ${unansweredIds.join(", ")}`;
|
||||
}
|
||||
|
||||
function validateQuestions(questions: AskUserQuestion[]): AskUserQuestion[] {
|
||||
if (questions.length === 0) throw new PendingAskValidationError("An ask must contain at least one question");
|
||||
if (questions.length > ASK_USER_QUESTION_LIMIT) {
|
||||
throw new PendingAskValidationError(`An ask must not contain more than ${ASK_USER_QUESTION_LIMIT.toString()} questions`);
|
||||
}
|
||||
const seenIds = new Set<string>();
|
||||
return questions.map((question) => {
|
||||
const id = requireId(question.id, "question id");
|
||||
if (seenIds.has(id)) throw new PendingAskValidationError(`Duplicate question id ${id}`);
|
||||
seenIds.add(id);
|
||||
return validateQuestion(question, id);
|
||||
});
|
||||
}
|
||||
|
||||
function validateQuestion(question: AskUserQuestion, id: string): AskUserQuestion {
|
||||
if (question.options.length > ASK_USER_OPTION_LIMIT) {
|
||||
throw new PendingAskValidationError(`Question ${id} must not offer more than ${ASK_USER_OPTION_LIMIT.toString()} options`);
|
||||
}
|
||||
const allowOther = question.allowOther === true;
|
||||
// A question with neither options nor a free-text field cannot be answered at
|
||||
// all, which would make its "unanswered" report meaningless.
|
||||
if (question.options.length === 0 && !allowOther) {
|
||||
throw new PendingAskValidationError(`Question ${id} must offer options or allow other text`);
|
||||
}
|
||||
const seenValues = new Set<string>();
|
||||
const options = question.options.map((option) => {
|
||||
const value = requireId(option.value, `option value of question ${id}`);
|
||||
if (seenValues.has(value)) throw new PendingAskValidationError(`Duplicate option value ${value} in question ${id}`);
|
||||
seenValues.add(value);
|
||||
return validateOption(option, value, id);
|
||||
});
|
||||
const detail = question.detail;
|
||||
return {
|
||||
id,
|
||||
question: requireText(question.question, `text of question ${id}`),
|
||||
...(detail === undefined ? {} : { detail: requireText(detail, `detail of question ${id}`) }),
|
||||
options,
|
||||
...(allowOther ? { allowOther: true } : {}),
|
||||
...(question.multiple === true ? { multiple: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateOption(option: AskUserQuestionOption, value: string, questionId: string): AskUserQuestionOption {
|
||||
const detail = option.detail;
|
||||
return {
|
||||
value,
|
||||
label: requireText(option.label, `label of option ${value} in question ${questionId}`),
|
||||
...(detail === undefined ? {} : { detail: requireText(detail, `detail of option ${value} in question ${questionId}`) }),
|
||||
};
|
||||
}
|
||||
|
||||
function validateSubmission(ask: PendingAskUser, submission: AskUserSubmission): Map<string, AskUserAnswer> {
|
||||
const questionsById = new Map(ask.questions.map((question) => [question.id, question]));
|
||||
const answers = new Map<string, AskUserAnswer>();
|
||||
for (const answer of submission.answers) {
|
||||
const question = questionsById.get(answer.id);
|
||||
if (question === undefined) throw new PendingAskValidationError(`Unknown question id ${answer.id}`);
|
||||
if (answers.has(answer.id)) throw new PendingAskValidationError(`Duplicate answer for question ${answer.id}`);
|
||||
const validated = validateAnswer(question, answer);
|
||||
// Untouched questions and explicitly empty answers are the same thing, so an
|
||||
// empty answer is dropped rather than recorded as answered.
|
||||
if (validated !== undefined) answers.set(answer.id, validated);
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
function validateAnswer(question: AskUserQuestion, answer: AskUserAnswer): AskUserAnswer | undefined {
|
||||
const optionValues = new Set(question.options.map((option) => option.value));
|
||||
const values: string[] = [];
|
||||
for (const value of answer.values) {
|
||||
if (!optionValues.has(value)) throw new PendingAskValidationError(`Question ${question.id} has no option ${value}`);
|
||||
if (values.includes(value)) throw new PendingAskValidationError(`Duplicate value ${value} for question ${question.id}`);
|
||||
values.push(value);
|
||||
}
|
||||
const otherText = normalizeOtherText(question, answer.otherText);
|
||||
const selectionCount = values.length + (otherText === undefined ? 0 : 1);
|
||||
if (question.multiple !== true && selectionCount > 1) {
|
||||
throw new PendingAskValidationError(`Question ${question.id} accepts a single answer`);
|
||||
}
|
||||
if (selectionCount === 0) return undefined;
|
||||
return { id: question.id, values, ...(otherText === undefined ? {} : { otherText }) };
|
||||
}
|
||||
|
||||
function normalizeOtherText(question: AskUserQuestion, otherText: string | undefined): string | undefined {
|
||||
if (otherText === undefined) return undefined;
|
||||
if (question.allowOther !== true) throw new PendingAskValidationError(`Question ${question.id} does not accept other text`);
|
||||
if (otherText.length > ASK_USER_OTHER_TEXT_MAX_LENGTH) {
|
||||
throw new PendingAskValidationError(`Other text of question ${question.id} exceeds its length limit`);
|
||||
}
|
||||
const trimmed = otherText.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function cloneAsk(ask: PendingAskUser): PendingAskUser {
|
||||
return { askId: ask.askId, askedAt: ask.askedAt, questions: ask.questions.map(cloneQuestion) };
|
||||
}
|
||||
|
||||
function cloneQuestion(question: AskUserQuestion): AskUserQuestion {
|
||||
return { ...question, options: question.options.map((option) => ({ ...option })) };
|
||||
}
|
||||
|
||||
function requireSessionId(sessionId: string): string {
|
||||
if (sessionId === "") throw new Error("sessionId must not be empty");
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function requireId(value: string, field: string): string {
|
||||
if (value.trim() === "") throw new PendingAskValidationError(`${field} must not be empty`);
|
||||
if (value.length > ASK_USER_ID_MAX_LENGTH) throw new PendingAskValidationError(`${field} exceeds its length limit`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value: string, field: string): string {
|
||||
if (value.trim() === "") throw new PendingAskValidationError(`${field} must not be empty`);
|
||||
if (value.length > ASK_USER_TEXT_MAX_LENGTH) throw new PendingAskValidationError(`${field} exceeds its length limit`);
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user