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:
Federico Jaramillo Martinez
2026-07-26 21:45:51 +02:00
parent 6fa57b524b
commit 49393e1cb9
3 changed files with 740 additions and 5 deletions
+338
View File
@@ -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"));
});
});
+341
View File
@@ -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;
}
+61 -5
View File
@@ -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