From 49393e1cb9de0d528f43f4d54c69cf8feb7925e9 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 26 Jul 2026 21:45:51 +0200 Subject: [PATCH] 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. --- src/server/sessions/pendingAskStore.test.ts | 338 +++++++++++++++++++ src/server/sessions/pendingAskStore.ts | 341 ++++++++++++++++++++ src/shared/apiTypes.ts | 66 +++- 3 files changed, 740 insertions(+), 5 deletions(-) create mode 100644 src/server/sessions/pendingAskStore.test.ts create mode 100644 src/server/sessions/pendingAskStore.ts diff --git a/src/server/sessions/pendingAskStore.test.ts b/src/server/sessions/pendingAskStore.test.ts new file mode 100644 index 0000000..264e567 --- /dev/null +++ b/src/server/sessions/pendingAskStore.test.ts @@ -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 { + 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[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")); + }); +}); diff --git a/src/server/sessions/pendingAskStore.ts b/src/server/sessions/pendingAskStore.ts new file mode 100644 index 0000000..bfa0703 --- /dev/null +++ b/src/server/sessions/pendingAskStore.ts @@ -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; + +/** + * 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(); + + 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(); + 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(); + 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 { + const questionsById = new Map(ask.questions.map((question) => [question.id, question])); + const answers = new Map(); + 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; +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 58d00c7..de24563 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -440,6 +440,17 @@ export interface QueuedSessionMessage { text: string; } +/** Largest question set one `ask_user` call may post. */ +export const ASK_USER_QUESTION_LIMIT = 20; +/** Largest option list one question may offer. */ +export const ASK_USER_OPTION_LIMIT = 12; +/** Length bound for ids: the ask id, question ids, and option values. */ +export const ASK_USER_ID_MAX_LENGTH = 128; +/** Length bound for model-authored prose: questions, details, and option labels. */ +export const ASK_USER_TEXT_MAX_LENGTH = 1_000; +/** Length bound for the free text a user types into an "other" field. */ +export const ASK_USER_OTHER_TEXT_MAX_LENGTH = 4_000; + /** One selectable option of an {@link AskUserQuestion}. */ export interface AskUserQuestionOption { /** Stable machine value reported back to the model. */ @@ -481,13 +492,58 @@ export interface PendingAskUser { questions: AskUserQuestion[]; } -/** - * Why an ask stopped being the session's open ask. The answer/outcome types that - * describe *what* the user replied arrive with the pending-ask store that - * computes them. - */ +/** Why an ask stopped being the session's open ask. */ export type AskUserCloseReason = "submitted" | "superseded" | "cancelled"; +/** + * What the user replied to one question. Absent from a submission means the + * question was left untouched; an empty `values` with no `otherText` means the + * same thing. + */ +export interface AskUserAnswer { + /** Matches an {@link AskUserQuestion.id} of the open ask. */ + id: string; + /** Selected {@link AskUserQuestionOption.value} entries; several only when the question allows it. */ + values: string[]; + /** Free text typed into the "other" field, when the question allows it. */ + otherText?: string; +} + +/** One submit of the open ask: answers for some or all of its questions. */ +export interface AskUserSubmission { + answers: AskUserAnswer[]; +} + +/** + * One question of a closed ask paired with what came back for it. Carries the + * question itself so the record renders without the original ask still existing. + */ +export interface AskUserQuestionRecord { + question: AskUserQuestion; + /** True when at least one option was selected or "other" text was given. */ + answered: boolean; + values: string[]; + otherText?: string; +} + +/** + * The complete result of an ask, computed when it closes. Shared by the + * model-facing follow-up message and the browser's read-only record, so both + * report the same answered and unanswered questions. + */ +export interface AskUserOutcome { + askId: string; + reason: AskUserCloseReason; + askedAt: string; + closedAt: string; + questions: AskUserQuestionRecord[]; + answeredCount: number; + /** Ids of the questions left unanswered, in the order they were asked. */ + unansweredIds: string[]; + /** One line, for example `Answered 3 of 5; unanswered: q2, q5`. */ + summary: string; +} + /** * Progress of the session startup window, where the daemon is still * constructing the agent session and no `PiAgentSession` exists yet, so