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
+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