fix(ui): streamline ask_user question forms

This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 10:55:49 +02:00
parent 4e47c23f23
commit 0e83146315
21 changed files with 261 additions and 209 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch
---
Add an `ask_user` session tool that lets agents post structured question sets as one browser form. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool with `askUser: false` or `PI_WEB_ASK_USER=false`.
Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool with `askUser: false` or `PI_WEB_ASK_USER=false`.
+3 -3
View File
@@ -810,9 +810,9 @@
<p>
The tool accepts one set of 120 questions. Each question has a unique <code>id</code>, its
<code>question</code> text, optional supporting <code>detail</code>, up to 12 options with stable values and
user-facing labels, and optional <code>allowOther</code> and <code>multiple</code> flags. A question must
offer at least one option or allow free text. No question is required: the user may leave any of them
unanswered.
user-facing labels, and an optional <code>multiple</code> flag. The browser always adds a
<strong>Custom</strong> free-text answer, including when the model supplies no options. No question is
required: the user may leave any of them unanswered.
</p>
<p>
Calling <code>ask_user</code> posts the whole set as one browser form and ends the current agent run
+1 -1
View File
@@ -275,7 +275,7 @@ In **Settings → Session daemon**, these keys are saved on the selected machine
`askUser` controls whether agents receive the core `ask_user` tool. It defaults to `true`; set it to `false`, or set `PI_WEB_ASK_USER=false`, to remove the tool. The environment override accepts `0|1|true|false` and takes precedence over the config file.
The tool accepts one set of 120 questions. Each question has a unique `id`, its `question` text, optional supporting `detail`, up to 12 options with stable values and user-facing labels, and optional `allowOther` and `multiple` flags. A question must offer at least one option or allow free text. No question is required: the user may leave any of them unanswered.
The tool accepts one set of 120 questions. Each question has a unique `id`, its `question` text, optional supporting `detail`, up to 12 options with stable values and user-facing labels, and an optional `multiple` flag. The browser always adds a **Custom** free-text answer, including when the model supplies no options. No question is required: the user may leave any of them unanswered.
Calling `ask_user` posts the whole set as one browser form and ends the current agent run instead of waiting for the user. The open form is owned by the session daemon, so it survives a browser disconnect, browser reload, or web/API restart while that daemon keeps running. When the user submits, the answers arrive as a follow-up that wakes the session; each question is reported with its selected option values or free text, or explicitly as unanswered.
+7 -5
View File
@@ -723,14 +723,14 @@ describe("API parsers", () => {
})).toThrow("Invalid notification clear reason");
});
it("parses an open ask with options, details, other, and multi-select", () => {
it("parses an open ask and normalizes every question to allow custom answers", () => {
const parsed = parseSessionStatus({ ...statusWire(), pendingAsk: pendingAskWire() });
expect(parsed.pendingAsk).toEqual({
askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z",
questions: [
{ id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }] },
{ id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }], allowOther: true },
{ id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true },
],
});
@@ -740,13 +740,15 @@ describe("API parsers", () => {
expect(parseSessionStatus(statusWire()).pendingAsk).toBeUndefined();
});
it("rejects an ask that cannot be rendered or answered honestly", () => {
it("validates an ask before rendering it", () => {
const ask = pendingAskWire();
const first = ask.questions[0];
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [] } })).toThrow("Pending ask has no questions");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [first, first] } })).toThrow("Duplicate ask question id");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, askId: "" } })).toThrow("Expected non-empty string field: askId");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Anything?", options: [] }] } })).toThrow("Ask question offers no way to answer");
expect(parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Anything?", options: [] }] } }).pendingAsk?.questions[0])
.toEqual({ id: "q1", question: "Anything?", options: [], allowOther: true });
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Anything?", options: [], allowOther: "yes" }] } })).toThrow("Expected optional boolean field: allowOther");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Which?", options: [{ value: "a", label: "A" }, { value: "a", label: "Also A" }] }] } })).toThrow("Duplicate ask option value");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "x".repeat(ASK_USER_TEXT_MAX_LENGTH + 1), options: [{ value: "a", label: "A" }] }] } })).toThrow("String field exceeds limit: question");
});
@@ -814,7 +816,7 @@ function pendingAskWire() {
askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z",
questions: [
{ id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }] },
{ id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }], allowOther: false },
{ id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true },
],
};
+4 -5
View File
@@ -218,17 +218,16 @@ function parseAskUserQuestion(value: unknown): AskUserQuestion {
const record = requireRecord(value);
const options = boundedArrayOf(record["options"], parseAskUserQuestionOption, ASK_USER_OPTION_LIMIT, "options");
assertUniqueStrings(options.map((option) => option.value), "ask option value");
const allowOther = parseOptionalBoolean(record["allowOther"], "allowOther");
// Validate the legacy wire field when present, but normalize every question to
// the current invariant: the browser always offers a custom answer.
parseOptionalBoolean(record["allowOther"], "allowOther");
const multiple = parseOptionalBoolean(record["multiple"], "multiple");
// A question offering neither options nor a free-text field cannot be answered
// at all, which would make reporting it as unanswered meaningless.
if (options.length === 0 && allowOther !== true) throw new Error("Ask question offers no way to answer");
return {
id: requireBoundedNonEmptyString(record, "id", ASK_USER_ID_MAX_LENGTH),
question: requireBoundedNonEmptyString(record, "question", ASK_USER_TEXT_MAX_LENGTH),
...optionalField("detail", optionalBoundedNonEmptyString(record, "detail", ASK_USER_TEXT_MAX_LENGTH)),
options,
...(allowOther === undefined ? {} : { allowOther }),
allowOther: true,
...(multiple === undefined ? {} : { multiple }),
};
}
+6 -10
View File
@@ -50,11 +50,10 @@ const singleSelect: AskUserQuestion = {
options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }],
};
const multiSelectWithOther: AskUserQuestion = {
const multiSelect: AskUserQuestion = {
id: "q2",
question: "Which extras?",
options: [{ value: "metrics", label: "Metrics" }, { value: "tracing", label: "Tracing" }],
allowOther: true,
multiple: true,
};
@@ -62,10 +61,9 @@ const freeTextOnly: AskUserQuestion = {
id: "q3",
question: "Anything else?",
options: [],
allowOther: true,
};
const questions = [singleSelect, multiSelectWithOther, freeTextOnly];
const questions = [singleSelect, multiSelect, freeTextOnly];
afterEach(() => {
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
@@ -151,7 +149,7 @@ describe("ask answer state", () => {
expect(toSubmission(questions, answers).answers.map((answer) => answer.id)).toEqual(["q1", "q3"]);
});
it("keeps several values and other text together for a multi-select question", () => {
it("keeps several values and custom text together for a multi-select question", () => {
const answers: AskDraftAnswers = { q2: { values: ["metrics", "tracing"], otherText: "profiling" } };
expect(toSubmission(questions, answers)).toEqual({
@@ -178,15 +176,13 @@ describe("ask answer state", () => {
});
});
it("keeps other text for a single-select question that has no selected option", () => {
const singleWithOther: AskUserQuestion = { ...singleSelect, allowOther: true };
expect(toSubmission([singleWithOther], { q1: { values: [], otherText: "neither" } })).toEqual({
it("keeps custom text for every single-select question when no option is selected", () => {
expect(toSubmission([singleSelect], { q1: { values: [], otherText: "neither" } })).toEqual({
answers: [{ id: "q1", values: [], otherText: "neither" }],
});
});
it("bounds other text at the shared limit", () => {
it("bounds custom text at the shared limit", () => {
const answers: AskDraftAnswers = { q3: { values: [], otherText: "a".repeat(ASK_USER_OTHER_TEXT_MAX_LENGTH + 10) } };
expect(toSubmission(questions, answers).answers[0]?.otherText).toHaveLength(ASK_USER_OTHER_TEXT_MAX_LENGTH);
+3 -3
View File
@@ -120,7 +120,7 @@ function submittableAnswer(question: AskUserQuestion, answer: AskDraftAnswer | u
if (answer === undefined) return undefined;
const offered = new Set(question.options.map((option) => option.value));
const values = [...new Set(answer.values)].filter((value) => offered.has(value));
const otherText = normalizedOtherText(question, answer.otherText);
const otherText = normalizedOtherText(answer.otherText);
if (question.multiple !== true && values.length + (otherText === undefined ? 0 : 1) > 1) {
const single = values[0];
if (single !== undefined) return { id: question.id, values: [single] };
@@ -130,8 +130,8 @@ function submittableAnswer(question: AskUserQuestion, answer: AskDraftAnswer | u
return { id: question.id, values, ...(otherText === undefined ? {} : { otherText }) };
}
function normalizedOtherText(question: AskUserQuestion, otherText: string | undefined): string | undefined {
if (otherText === undefined || question.allowOther !== true) return undefined;
function normalizedOtherText(otherText: string | undefined): string | undefined {
if (otherText === undefined) return undefined;
const trimmed = otherText.trim().slice(0, ASK_USER_OTHER_TEXT_MAX_LENGTH);
return trimmed === "" ? undefined : trimmed;
}
+2 -2
View File
@@ -10,12 +10,12 @@ const askUserOutcome: AskUserOutcome = {
closedAt: "2026-07-20T10:05:00.000Z",
questions: [
{
question: { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] },
question: { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true },
answered: true,
values: ["pg"],
},
{
question: { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }] },
question: { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }], allowOther: true },
answered: false,
values: [],
},
+2 -2
View File
@@ -12,12 +12,12 @@ const askUserOutcome: AskUserOutcome = {
closedAt: "2026-07-20T10:05:00.000Z",
questions: [
{
question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] },
question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }], allowOther: true },
answered: true,
values: ["vim"],
},
{
question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }] },
question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }], allowOther: true },
answered: false,
values: [],
},
+33 -13
View File
@@ -33,7 +33,7 @@ describe("ask-user-card live form", () => {
expect(code.name).toBe(vim.name);
expect(web.type).toBe("checkbox");
expect(web.name).not.toBe(vim.name);
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("Answered 0 of 2");
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("0 of 2 answered");
// Focus and interaction run through the rendered native control rather than
// extracting Lit handlers, so this exercises the form's browser boundary.
@@ -44,7 +44,7 @@ describe("ask-user-card live form", () => {
expect(vim.checked).toBe(true);
expect(code.checked).toBe(false);
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("Answered 1 of 2");
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("1 of 2 answered");
});
it("accumulates several checkbox values for a multi-select question", async () => {
@@ -58,7 +58,7 @@ describe("ask-user-card live form", () => {
inputWithValue(root, "desktop").click();
await card.updateComplete;
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("Answered 1 of 1");
expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("1 of 1 answered");
buttonWithText(root, "Send answers").click();
await Promise.resolve();
@@ -67,10 +67,10 @@ describe("ask-user-card live form", () => {
});
});
it("reveals and focuses a labelled other field while preserving multi-select options", async () => {
it("always offers and focuses a labelled custom field while preserving multi-select options", async () => {
const onSubmit = vi.fn<AskUserSubmitCallback>();
const card = await mountOpenAsk(openAsk([
question("stack", "Pick the stack", [option("lit", "Lit"), option("react", "React")], { multiple: true, allowOther: true }),
question("stack", "Pick the stack", [option("lit", "Lit"), option("react", "React")], { multiple: true }),
]), onSubmit);
const root = renderRoot(card);
@@ -79,9 +79,9 @@ describe("ask-user-card live form", () => {
await card.updateComplete;
await Promise.resolve();
const textarea = requiredElement(root.querySelector("textarea"), "other textarea");
const label = requiredElement(textarea.closest("label"), "other label");
expect(label.textContent).toContain("Your answer for “Pick the stack”");
const textarea = requiredElement(root.querySelector("textarea"), "custom textarea");
const label = requiredElement(textarea.closest("label"), "custom label");
expect(label.textContent).toContain("Custom answer");
expect(root.activeElement).toBe(textarea);
textarea.value = "Svelte";
@@ -95,12 +95,33 @@ describe("ask-user-card live form", () => {
});
});
it("shows and submits the custom field directly when no options were supplied", async () => {
const onSubmit = vi.fn<AskUserSubmitCallback>();
const card = await mountOpenAsk(openAsk([
question("notes", "Anything else?", []),
]), onSubmit);
const root = renderRoot(card);
const textarea = requiredElement(root.querySelector("textarea"), "custom textarea");
expect(root.querySelector("input")).toBeNull();
expect(requiredElement(textarea.closest("label"), "custom label").textContent).toContain("Custom answer");
textarea.value = "Keep the first version small.";
textarea.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
await card.updateComplete;
buttonWithText(root, "Send answers").click();
await Promise.resolve();
expect(onSubmit).toHaveBeenCalledWith("ask-1", {
answers: [{ id: "notes", values: [], otherText: "Keep the first version small." }],
});
});
it("names unanswered questions before allowing a partial submit", async () => {
const onSubmit = vi.fn<AskUserSubmitCallback>();
const card = await mountOpenAsk(openAsk([
question("editor", "Choose an editor", [option("vim", "Vim")]),
question("deploy", "Choose a deployment target", [option("cloud", "Cloud")]),
question("notes", "Add implementation notes", [], { allowOther: true }),
question("notes", "Add implementation notes", []),
]), onSubmit);
const root = renderRoot(card);
@@ -139,7 +160,7 @@ describe("ask-user-card record mode", () => {
closedAt: "2026-07-20T10:05:00.000Z",
questions: [
unansweredRecord(question("speed", "Preferred pace", [option("fast", "Fast"), option("careful", "Careful")])),
unansweredRecord(question("rationale", "Why?", [], { allowOther: true })),
unansweredRecord(question("rationale", "Why?", [])),
unansweredRecord(question("region", "Deployment region", [option("eu", "Europe")])),
],
answeredCount: 0,
@@ -154,7 +175,7 @@ describe("ask-user-card record mode", () => {
const root = renderRoot(card);
expect(root.querySelector("input, textarea, button, select")).toBeNull();
expect(root.textContent).toContain("Questions superseded");
expect(root.textContent).toContain("Superseded");
expect(root.textContent).toContain("Fast");
expect(root.textContent).toContain("It keeps the feedback loop short.");
expect(root.textContent).toContain("Draft answer · not sent");
@@ -200,7 +221,7 @@ function question(
id: string,
text: string,
options: AskUserQuestion["options"],
settings: { detail?: string; multiple?: boolean; allowOther?: boolean } = {},
settings: { detail?: string; multiple?: boolean } = {},
): AskUserQuestion {
return {
id,
@@ -208,7 +229,6 @@ function question(
options,
...(settings.detail === undefined ? {} : { detail: settings.detail }),
...(settings.multiple === undefined ? {} : { multiple: settings.multiple }),
...(settings.allowOther === undefined ? {} : { allowOther: settings.allowOther }),
};
}
+73 -112
View File
@@ -70,20 +70,16 @@ export class AskUserCard extends LitElement {
return html`
<article class="card open-card" aria-labelledby="ask-user-heading">
<header class="card-header">
<div>
<p class="eyebrow">Pi needs your input</p>
<h2 id="ask-user-heading">Questions from the model</h2>
</div>
<span class="question-total">${ask.questions.length} ${ask.questions.length === 1 ? "question" : "questions"}</span>
<h2 id="ask-user-heading">Questions</h2>
<span class="header-status" role="status" aria-live="polite" aria-atomic="true">
${count} of ${ask.questions.length} answered
</span>
</header>
<form class="ask-form" @submit=${(event: SubmitEvent) => { this.handleSubmit(event, ask); }}>
<div class="questions-grid">
<div class="questions">
${ask.questions.map((question, index) => this.renderQuestion(ask, question, index))}
</div>
<footer class="form-footer">
<div class="progress" role="status" aria-live="polite" aria-atomic="true">
Answered ${count} of ${ask.questions.length}
</div>
${this.confirmingPartialSubmit && unanswered.length > 0
? this.renderPartialSubmitConfirmation(ask, unanswered)
: html`
@@ -101,7 +97,8 @@ export class AskUserCard extends LitElement {
const answer = this.answers[question.id];
const answered = answeredCount([question], this.answers) === 1;
const detailId = question.detail === undefined ? undefined : this.questionDetailId(index);
const otherSelected = this.isOtherSelected(question, answer);
const freeTextOnly = question.options.length === 0;
const customSelected = freeTextOnly || this.isOtherSelected(question, answer);
const inputType = question.multiple === true ? "checkbox" : "radio";
return html`
<fieldset
@@ -113,7 +110,6 @@ export class AskUserCard extends LitElement {
<legend>
<span class="question-number">${String(index + 1)}.</span>
<span>${question.question}</span>
<span class=${`answer-marker${answered ? " complete" : ""}`} aria-hidden="true">${answered ? "Answered" : "Unanswered"}</span>
</legend>
${question.detail === undefined ? null : html`<p class="question-detail" id=${detailId}>${question.detail}</p>`}
<div class="options">
@@ -132,29 +128,29 @@ export class AskUserCard extends LitElement {
</span>
</label>
`)}
${question.allowOther === true ? html`
${freeTextOnly ? null : html`
<label class="option other-option">
<input
type=${inputType}
name=${this.questionGroupName(ask, question)}
value="__pi_web_other__"
.checked=${otherSelected}
.checked=${customSelected}
@change=${(event: Event) => { this.changeOther(question, index, event); }}
/>
<span class="option-copy"><span class="option-label">Other</span></span>
<span class="option-copy"><span class="option-label">Custom</span></span>
</label>
`}
${customSelected ? html`
<label class="other-answer" for=${this.otherInputId(index)}>
<span>Custom answer</span>
<textarea
id=${this.otherInputId(index)}
rows="3"
maxlength=${String(ASK_USER_OTHER_TEXT_MAX_LENGTH)}
.value=${answer?.otherText ?? ""}
@input=${(event: Event) => { this.changeOtherText(question, event); }}
></textarea>
</label>
${otherSelected ? html`
<label class="other-answer" for=${this.otherInputId(index)}>
<span>Your answer for “${question.question}”</span>
<textarea
id=${this.otherInputId(index)}
rows="3"
maxlength=${String(ASK_USER_OTHER_TEXT_MAX_LENGTH)}
.value=${answer?.otherText ?? ""}
@input=${(event: Event) => { this.changeOtherText(question, event); }}
></textarea>
</label>
` : null}
` : null}
</div>
</fieldset>
@@ -186,16 +182,13 @@ export class AskUserCard extends LitElement {
const recordLabel = outcome.reason === "submitted"
? "Answers sent"
: outcome.reason === "superseded"
? "Questions superseded"
: "Questions cancelled";
? "Superseded"
: "Cancelled";
return html`
<article class="card record-card" aria-labelledby="ask-user-record-heading">
<header class="card-header">
<div>
<p class="eyebrow">Question record</p>
<h2 id="ask-user-record-heading">${recordLabel}</h2>
</div>
<span class=${`record-reason ${outcome.reason}`}>${outcome.reason}</span>
<h2 id="ask-user-record-heading">Questions</h2>
<span class=${`header-status ${outcome.reason}`}>${recordLabel}</span>
</header>
<p class="record-summary">
${outcome.reason === "superseded"
@@ -223,7 +216,7 @@ export class AskUserCard extends LitElement {
: html`
<ul class="record-answers">
${answer.values.map((value) => html`<li>${this.optionLabel(record.question, value)}</li>`)}
${answer.otherText === undefined ? null : html`<li><strong>Other:</strong> <span class="other-record-text">${answer.otherText}</span></li>`}
${answer.otherText === undefined ? null : html`<li><strong>Custom:</strong> <span class="other-record-text">${answer.otherText}</span></li>`}
</ul>
${answer.fromDraft ? html`<p class="draft-note">Draft answer · not sent</p>` : null}
`}
@@ -339,7 +332,7 @@ export class AskUserCard extends LitElement {
}
private isOtherSelected(question: AskUserQuestion, answer: AskDraftAnswer | undefined): boolean {
if (question.allowOther !== true || answer?.otherText === undefined) return false;
if (answer?.otherText === undefined) return false;
return question.multiple === true || answer.values.length === 0;
}
@@ -400,93 +393,69 @@ export class AskUserCard extends LitElement {
static override styles = css`
:host {
display: block;
width: min(100%, 780px);
margin: 18px auto;
box-sizing: border-box;
width: 100%;
margin: 0 0 14px;
color: var(--pi-text);
font: 14px system-ui, sans-serif;
container-type: inline-size;
}
.card {
overflow: hidden;
border: 1px solid var(--pi-border);
border-radius: 12px;
border-radius: 10px;
background: var(--pi-surface);
box-shadow: 0 10px 30px var(--pi-shadow-soft);
}
.card-header {
position: sticky;
top: var(--pi-chat-sticky-top, 0px);
z-index: 6;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--pi-border-muted);
background: var(--pi-bg-overlay);
}
.eyebrow {
margin: 0 0 3px;
color: var(--pi-accent);
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
min-height: 22px;
padding: 7px 10px 6px;
border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent);
border-radius: 9px 9px 0 0;
background: var(--pi-surface);
box-shadow: 0 8px 18px var(--pi-shadow-soft);
}
h2, h3, p { margin-top: 0; }
h2 { margin-bottom: 0; font-size: 16px; line-height: 1.3; }
.question-total, .record-reason {
flex: 0 0 auto;
border: 1px solid var(--pi-border-muted);
border-radius: 999px;
color: var(--pi-muted);
padding: 3px 8px;
font-size: 11px;
}
.record-reason { text-transform: capitalize; }
.record-reason.submitted { border-color: var(--pi-success-border); color: var(--pi-success); }
.record-reason.superseded { border-color: var(--pi-warning-border); color: var(--pi-warning); }
.ask-form {
max-height: min(72dvh, 680px);
overflow: auto;
overscroll-behavior: contain;
}
.questions-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 12px;
padding: 14px;
h2 {
margin-bottom: 0;
color: var(--pi-accent);
font-size: 12px;
line-height: 1.3;
text-transform: uppercase;
}
.header-status { flex: 0 1 auto; color: var(--pi-muted); font-size: 11px; text-align: end; }
.header-status.submitted { color: var(--pi-success); }
.header-status.superseded { color: var(--pi-warning); }
.questions { display: grid; padding-top: 8px; }
fieldset.question {
min-width: 0;
margin: 0;
border: 1px solid var(--pi-border-muted);
border-radius: 10px;
padding: 12px;
background: var(--pi-bg);
border: 0;
border-top: 1px solid var(--pi-border-muted);
padding: 16px;
background: transparent;
}
fieldset.question.answered { border-color: var(--pi-success-border); }
fieldset.question:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
fieldset.question:first-child { border-top: 0; }
fieldset.question:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -3px; }
legend {
box-sizing: border-box;
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 5px;
color: var(--pi-text);
padding: 0 2px;
padding: 0;
font-weight: 650;
line-height: 1.35;
}
.question-number { color: var(--pi-muted); }
.answer-marker {
border-radius: 999px;
background: var(--pi-surface-hover);
color: var(--pi-muted);
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
}
.answer-marker.complete { background: var(--pi-success-surface); color: var(--pi-success); }
fieldset.question.answered .question-number { color: var(--pi-success); }
.question-detail {
margin: 4px 0 10px;
color: var(--pi-muted);
@@ -511,7 +480,8 @@ export class AskUserCard extends LitElement {
.option-copy { min-width: 0; display: grid; gap: 2px; }
.option-label { line-height: 1.35; }
.option-detail { color: var(--pi-muted); font-size: 12px; line-height: 1.35; }
.other-answer { display: grid; gap: 5px; color: var(--pi-muted); font-size: 12px; padding: 0 8px 4px 32px; }
.other-answer { display: grid; gap: 5px; color: var(--pi-muted); font-size: 12px; padding: 4px 8px 4px 32px; }
.other-answer:only-child { padding-left: 0; padding-right: 0; }
textarea {
box-sizing: border-box;
width: 100%;
@@ -526,20 +496,13 @@ export class AskUserCard extends LitElement {
line-height: 1.4;
}
.form-footer {
position: sticky;
z-index: 2;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 12px;
border-top: 1px solid var(--pi-border);
background: var(--pi-bg-overlay);
padding: 10px 14px;
box-shadow: 0 -8px 18px var(--pi-shadow-soft);
backdrop-filter: blur(8px);
border-top: 1px solid var(--pi-border-muted);
padding: 12px 16px;
}
.progress { flex: 0 0 auto; color: var(--pi-muted); font-size: 12px; font-weight: 650; }
button {
border: 1px solid var(--pi-border);
border-radius: 8px;
@@ -566,24 +529,22 @@ export class AskUserCard extends LitElement {
text-underline-offset: 2px;
}
.confirmation-actions { flex: 0 0 auto; display: flex; gap: 7px; }
.record-summary { margin: 0; border-bottom: 1px solid var(--pi-border-muted); color: var(--pi-muted); padding: 10px 16px; font-size: 12px; }
.record-questions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 14px; }
.record-question { min-width: 0; border: 1px solid var(--pi-border-muted); border-radius: 10px; background: var(--pi-bg); padding: 12px; }
.record-summary { margin: 0; color: var(--pi-muted); padding: 12px 16px; font-size: 12px; }
.record-questions { display: grid; }
.record-question { min-width: 0; padding: 14px 16px; }
.record-question + .record-question { border-top: 1px solid var(--pi-border-muted); }
.record-question h3 { display: flex; gap: 5px; margin-bottom: 8px; font-size: 14px; line-height: 1.35; }
.record-answers { display: grid; gap: 4px; margin: 0; padding-left: 22px; line-height: 1.4; }
.other-record-text { white-space: pre-wrap; overflow-wrap: anywhere; }
.unanswered-record { margin: 0; color: var(--pi-muted); font-style: italic; }
.draft-note { margin: 7px 0 0; color: var(--pi-warning); font-size: 11px; }
@container (max-width: 580px) {
:host { margin: 12px 0; }
.questions-grid, .record-questions { grid-template-columns: minmax(0, 1fr); padding: 10px; }
.card-header { padding: 12px; }
.form-footer { align-items: stretch; flex-direction: column; }
fieldset.question, .record-question { padding: 14px 12px; }
.record-summary { padding-inline: 12px; }
.form-footer { align-items: stretch; flex-direction: column; padding: 12px; }
.partial-confirmation { align-items: stretch; flex-direction: column; }
.confirmation-actions { justify-content: flex-end; }
.primary-action { min-height: 42px; }
legend { grid-template-columns: auto minmax(0, 1fr); }
.answer-marker { grid-column: 2; justify-self: start; }
}
`;
}
@@ -9,6 +9,30 @@ afterEach(() => {
document.body.replaceChildren();
});
describe("ChatView open ask_user form", () => {
it("scrolls a newly opened form to its start and gives it a stable chat-scroll anchor", async () => {
const view = new ChatView();
view.sessionId = "session-1";
document.body.append(view);
await view.updateComplete;
let askStartScrolls = 0;
let bottomScrolls = 0;
if (!Reflect.set(view, "scrollToOpenAsk", () => { askStartScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToOpenAsk");
if (!Reflect.set(view, "scrollToBottom", () => { bottomScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom");
view.pendingAsk = {
askId: "ask-open",
askedAt: "2026-07-20T10:00:00.000Z",
questions: [{ id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] }],
};
await view.updateComplete;
expect(askStartScrolls).toBe(1);
expect(bottomScrolls).toBe(0);
expect(view.shadowRoot?.querySelector("ask-user-card")?.getAttribute("data-scroll-anchor-id")).toBe("ask:ask-open");
});
});
describe("ChatView ask_user transcript records", () => {
it("renders a projected outcome as the read-only question card with the machine-scoped draft key", async () => {
const outcome: AskUserOutcome = {
+44 -2
View File
@@ -221,6 +221,7 @@ export class ChatView extends LitElement {
private suppressLoadMoreRequests = false;
private loadMoreCheckFrame: number | undefined;
private scrollToBottomFrame: number | undefined;
private scrollToOpenAskFrame: number | undefined;
private conversationRailFrame: number | undefined;
private groupedMessagesInput?: ChatLine[];
private groupedMessagesStart = 0;
@@ -279,6 +280,10 @@ export class ChatView extends LitElement {
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame);
if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
if (this.scrollToOpenAskFrame !== undefined) {
cancelAnimationFrame(this.scrollToOpenAskFrame);
this.scrollToOpenAskFrame = undefined;
}
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
window.removeEventListener("resize", this.onViewportResize);
window.removeEventListener("pagehide", this.onPageHide);
@@ -305,6 +310,10 @@ export class ChatView extends LitElement {
cancelAnimationFrame(this.restoreScrollFrame);
this.restoreScrollFrame = undefined;
}
if (this.scrollToOpenAskFrame !== undefined) {
cancelAnimationFrame(this.scrollToOpenAskFrame);
this.scrollToOpenAskFrame = undefined;
}
}
protected override willUpdate(changed: Map<string, unknown>): void {
@@ -328,9 +337,13 @@ export class ChatView extends LitElement {
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
if (changed.has("sessionId")) this.restoreScrollPosition();
if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk")) && this.pinnedToBottom) this.scrollToBottom();
const openedAsk = changed.has("pendingAsk") && this.isNewPendingAsk(changed.get("pendingAsk"));
// The form uses the transcript scroller. Start a new long form at question
// one rather than applying the usual live-tail scroll and landing at its end.
if (!changed.has("sessionId") && openedAsk && this.pinnedToBottom) this.scrollToOpenAsk();
else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk")) && this.pinnedToBottom) this.scrollToBottom();
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore();
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk")) this.continuePendingScrollRestore();
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget();
if (changed.has("zoomedImage")) this.syncImageZoomDialog();
@@ -647,6 +660,7 @@ export class ChatView extends LitElement {
if (this.pendingAsk === undefined) return null;
return html`
<ask-user-card
data-scroll-anchor-id=${`ask:${this.pendingAsk.askId}`}
.ask=${this.pendingAsk}
.draftSessionId=${this.askDraftSessionId}
.onSubmit=${this.onSubmitAsk}
@@ -1011,6 +1025,33 @@ export class ChatView extends LitElement {
});
}
private isNewPendingAsk(previous: unknown): boolean {
return this.pendingAsk !== undefined
&& (typeof previous !== "object" || previous === null || Reflect.get(previous, "askId") !== this.pendingAsk.askId);
}
private scrollToOpenAsk(): void {
if (this.scrollToOpenAskFrame !== undefined) return;
if (this.scrollToBottomFrame !== undefined) {
cancelAnimationFrame(this.scrollToBottomFrame);
this.scrollToBottomFrame = undefined;
}
this.scrollToOpenAskFrame = requestAnimationFrame(() => {
this.scrollToOpenAskFrame = undefined;
this.withSuppressedScrollSave(() => { this.alignOpenAskToTop(); });
});
}
private alignOpenAskToTop(): boolean {
const chat = this.chat;
const card = this.renderRoot.querySelector<HTMLElement>(".chat > ask-user-card");
if (chat === undefined || card === null) return false;
chat.scrollTop += card.getBoundingClientRect().top - chat.getBoundingClientRect().top;
this.syncScrollMetrics();
this.pinnedToBottom = this.isNearBottom();
return true;
}
restoreScrollPosition() {
const sessionId = this.sessionId;
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
@@ -1018,6 +1059,7 @@ export class ChatView extends LitElement {
this.restoreScrollFrame = undefined;
if (this.sessionId !== sessionId) return;
this.withSuppressedScrollSave(() => {
if (this.pendingAsk !== undefined && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenAskToTop()) return;
const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() });
this.handleScrollRestoreResult(sessionId, result);
});
+1 -1
View File
@@ -365,7 +365,7 @@ export const chatStyles = css`
.notification-header { gap: 4px; padding-inline: 8px; }
.notification-list { padding-inline: 8px; }
}
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
.chat { --pi-chat-sticky-top: -26px; height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
.activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); }
+1 -1
View File
@@ -118,7 +118,7 @@ describe("notification socket guards", () => {
const ask = {
askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z",
questions: [{ id: "q1", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }],
questions: [{ id: "q1", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true }],
};
expect(parseSessionSocketEvent({ type: "ask.opened", ask })).toEqual({ type: "ask.opened", ask });
+6 -4
View File
@@ -29,7 +29,7 @@ function toolOverStore(askIds: string[] = ["ask-1", "ask-2"]) {
const twoQuestions = {
questions: [
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] },
{ id: "why", question: "Why?", options: [], allowOther: true },
{ id: "why", question: "Why?", options: [] },
],
};
@@ -53,6 +53,7 @@ describe("createAskUserToolDefinition", () => {
required: ["questions"],
properties: { questions: { type: "array", minItems: 1, maxItems: 20 } },
});
expect(tool.parameters).not.toHaveProperty("properties.questions.items.properties.allowOther");
});
it("opens the ask for the calling session and terminates the run instead of awaiting the user", async () => {
@@ -63,7 +64,7 @@ describe("createAskUserToolDefinition", () => {
expect(open).toHaveBeenCalledWith({
sessionId: "session-1",
questions: [
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] },
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }], allowOther: true },
{ id: "why", question: "Why?", options: [], allowOther: true },
],
});
@@ -75,7 +76,7 @@ describe("createAskUserToolDefinition", () => {
it("defaults a question without options to an empty option list so free text alone is expressible", async () => {
const { open, tool } = toolOverStore();
await tool.execute("call-free", { questions: [{ id: "note", question: "Anything else?", allowOther: true }] }, undefined, undefined, ctxFor("session-1"));
await tool.execute("call-free", { questions: [{ id: "note", question: "Anything else?" }] }, undefined, undefined, ctxFor("session-1"));
expect(open).toHaveBeenCalledWith({
sessionId: "session-1",
@@ -83,7 +84,7 @@ describe("createAskUserToolDefinition", () => {
});
});
it("forwards per-question detail, option detail, and multi-select without inventing defaults", async () => {
it("adds custom answers while preserving detail, option detail, and multi-select", async () => {
const { open, tool } = toolOverStore();
await tool.execute("call-rich", {
@@ -103,6 +104,7 @@ describe("createAskUserToolDefinition", () => {
question: "Which targets?",
detail: "Pick every platform we should build for.",
options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }],
allowOther: true,
multiple: true,
}],
});
+6 -7
View File
@@ -17,7 +17,7 @@ export interface AskUserInvocation {
}
export interface AskUserToolDeps {
/** Registers the ask as the session's open one; rejects question sets the user could not answer. */
/** Registers the ask as the session's open one; rejects malformed question sets. */
open(input: AskUserInvocation): Promise<PendingAskOpenResult>;
}
@@ -53,10 +53,7 @@ const AskUserQuestionParams = Type.Object({
})),
options: Type.Optional(Type.Array(AskUserOptionParams, {
maxItems: ASK_USER_OPTION_LIMIT,
description: "Options to choose from. Omit only when free text is the whole answer, and then set allowOther.",
})),
allowOther: Type.Optional(Type.Boolean({
description: "Offer a free-text field alongside the options.",
description: "Options to choose from. Omit when free text is the whole answer; the browser always adds a Custom choice.",
})),
multiple: Type.Optional(Type.Boolean({
description: "Allow several options at once. Default: one answer per question.",
@@ -73,13 +70,15 @@ const AskUserParams = Type.Object({
/** Shapes one schema question into the domain question; the store owns validation. */
function toQuestion(param: Static<typeof AskUserQuestionParams>): AskUserQuestion {
const { detail, options, allowOther, multiple } = param;
const { detail, options, multiple } = param;
return {
id: param.id,
question: param.question,
...(detail === undefined ? {} : { detail }),
options: (options ?? []).map(toOption),
...(allowOther === undefined ? {} : { allowOther }),
// Keep the compatibility marker on the daemon wire even though the model no
// longer chooses whether a question accepts a custom answer.
allowOther: true,
...(multiple === undefined ? {} : { multiple }),
};
}
+16 -14
View File
@@ -45,7 +45,7 @@ describe("PendingAskStore validation", () => {
question: "Which database?",
detail: "Only the primary store matters here.",
options: [{ value: "pg", label: "Postgres", detail: "Existing cluster" }],
allowOther: true,
allowOther: false,
multiple: false,
},
],
@@ -79,7 +79,6 @@ describe("PendingAskStore validation", () => {
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: " " }] })]))
@@ -90,9 +89,9 @@ describe("PendingAskStore validation", () => {
expect(store.pendingAsk(sessionId)).toBeUndefined();
});
it("accepts a question that only offers free text", () => {
it("accepts an optionless question and adds the custom-answer compatibility marker", () => {
const store = testStore();
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [], allowOther: true })] });
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [] })] });
expect(ask.questions[0]).toEqual({ id: "q1", question: "Question q1?", options: [], allowOther: true });
});
});
@@ -143,7 +142,7 @@ describe("PendingAskStore submit", () => {
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 { ask } = store.open({ sessionId, questions: [question("q1"), question("q2")] });
const result = store.submit(sessionId, ask.askId, {
answers: [{ id: "q1", values: [] }, { id: "q2", values: [], otherText: " " }],
@@ -161,11 +160,11 @@ describe("PendingAskStore submit", () => {
expect(store.pendingAsk(sessionId)?.askId).toBe(ask.askId);
});
it("accepts several values and coexisting other text for a multi-select question", () => {
it("accepts several values and coexisting custom text for a multi-select question", () => {
const store = testStore();
const { ask } = store.open({
sessionId,
questions: [question("q1", { multiple: true, allowOther: true })],
questions: [question("q1", { multiple: true })],
});
const result = store.submit(sessionId, ask.askId, {
@@ -181,17 +180,20 @@ describe("PendingAskStore submit", () => {
});
});
it("rejects other text for a question that does not allow it", () => {
it("accepts custom text for every question", () => {
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/);
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "custom" }] });
expect(result).toMatchObject({ status: "closed", outcome: { answeredCount: 1 } });
if (result.status !== "closed") throw new Error("expected the ask to close");
expect(result.outcome.questions[0]).toMatchObject({ answered: true, values: [], otherText: "custom" });
});
it("answers a free-text-only question with other text alone", () => {
it("answers a free-text-only question with custom text alone", () => {
const store = testStore();
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [], allowOther: true })] });
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [] })] });
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "a note" }] });
@@ -294,7 +296,7 @@ describe("ask outcome rendering", () => {
const store = testStore();
const { ask } = store.open({
sessionId,
questions: [question("q1"), question("q2", { allowOther: true }), question("q3")],
questions: [question("q1"), question("q2"), question("q3")],
});
const result = store.submit(sessionId, ask.askId, {
answers: [{ id: "q1", values: ["yes"] }, { id: "q2", values: [], otherText: "something else" }],
@@ -307,7 +309,7 @@ describe("ask outcome rendering", () => {
"- q1: Question q1?",
" Answered: selected yes",
"- q2: Question q2?",
` Answered: other: "something else"`,
` Answered: custom: "something else"`,
"- q3: Question q3?",
" Unanswered.",
"",
+5 -10
View File
@@ -47,7 +47,7 @@ export type PendingAskCloseResult =
| { status: "closed"; outcome: AskUserOutcome }
| { status: "stale" };
/** Rejected input: the model asked something unanswerable, or an answer does not fit its question. */
/** Rejected input: a question set is malformed, or an answer does not fit its question. */
export class PendingAskValidationError extends Error {
constructor(message: string) {
super(message);
@@ -181,7 +181,7 @@ 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)}`);
if (record.otherText !== undefined) parts.push(`custom: ${JSON.stringify(record.otherText)}`);
return [header, ` Answered: ${parts.join("; ")}`];
}
@@ -240,12 +240,6 @@ function validateQuestion(question: AskUserQuestion, id: string): AskUserQuestio
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}`);
@@ -259,7 +253,9 @@ function validateQuestion(question: AskUserQuestion, id: string): AskUserQuestio
question: requireText(question.question, `text of question ${id}`),
...(detail === undefined ? {} : { detail: requireText(detail, `detail of question ${id}`) }),
options,
...(allowOther ? { allowOther: true } : {}),
// Every question accepts a custom answer. Retain the marker so older web
// clients also expose the field when connected to this daemon.
allowOther: true,
...(question.multiple === true ? { multiple: true } : {}),
};
}
@@ -307,7 +303,6 @@ function validateAnswer(question: AskUserQuestion, answer: AskUserAnswer): AskUs
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`);
}
@@ -7,7 +7,7 @@ import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, runtimeCreato
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
const ACTIVE_SESSION_ID = "session-1";
const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }];
const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true }];
/**
* Service over a clocked store with sequential ask ids, so asks are named
@@ -98,13 +98,14 @@ describe("PiSessionService.openAsk", () => {
await service.dispose();
});
it("rejects an unanswerable question set without opening it", async () => {
it("opens an optionless question with a custom answer", async () => {
const { service, store, events } = askService();
await expect(service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "No way to answer?", options: [] }] }))
.rejects.toThrow(PendingAskValidationError);
expect(store.pendingAsk("session-1")).toBeUndefined();
expect(askEvents(events)).toEqual([]);
const result = await service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "Anything else?", options: [] }] });
expect(result.ask.questions).toEqual([{ id: "empty", question: "Anything else?", options: [], allowOther: true }]);
expect(store.pendingAsk("session-1")).toEqual(result.ask);
expect(askEvents(events)).toHaveLength(1);
await service.dispose();
});
@@ -163,11 +164,16 @@ describe("PiSessionService ask status projection", () => {
});
describe("PiSessionService.submitAsk", () => {
it("delivers the answers as a follow-up custom message that wakes the session", async () => {
it("delivers a custom answer as a follow-up message that wakes the session", async () => {
const { service, store, events, fake } = askService({ withActiveSession: true });
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
await service.openAsk({
sessionId: ACTIVE_SESSION_ID,
questions: [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: false }],
});
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "db", values: ["pg"] }] });
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", {
answers: [{ id: "db", values: [], otherText: "DuckDB" }],
});
expect(response).toMatchObject({ result: "closed", outcome: { askId: "ask-1", reason: "submitted", answeredCount: 1, unansweredIds: [] } });
expect(response.sessionStatus.sessionId).toBe(ACTIVE_SESSION_ID);
@@ -176,6 +182,7 @@ describe("PiSessionService.submitAsk", () => {
expect(delivered?.message.customType).toBe(ASK_USER_ANSWERS_CUSTOM_TYPE);
expect(delivered?.message.display).toBe(true);
expect(delivered?.message.content).toContain("The user submitted answers to your questions.");
expect(delivered?.message.content).toContain(`custom: "DuckDB"`);
expect(delivered?.message.content).toContain("Answered 1 of 1");
expect(delivered?.message.details).toMatchObject({ askId: "ask-1", reason: "submitted" });
expect(delivered?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
+7 -4
View File
@@ -462,7 +462,7 @@ export const ASK_USER_OPTION_LIMIT = 12;
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. */
/** Length bound for the free text a user types as a custom answer. */
export const ASK_USER_OTHER_TEXT_MAX_LENGTH = 4_000;
/** One selectable option of an {@link AskUserQuestion}. */
@@ -489,7 +489,10 @@ export interface AskUserQuestion {
detail?: string;
/** Offered options; may be empty when only free text makes sense. */
options: AskUserQuestionOption[];
/** When true, a labelled free-text field is offered alongside the options. */
/**
* Compatibility marker for older clients. Canonical questions set this to
* true because every question offers a custom free-text answer.
*/
allowOther?: boolean;
/** When true, several options may be selected at once. */
multiple?: boolean;
@@ -519,7 +522,7 @@ export interface AskUserAnswer {
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. */
/** Free text typed as the question's custom answer. */
otherText?: string;
}
@@ -534,7 +537,7 @@ export interface AskUserSubmission {
*/
export interface AskUserQuestionRecord {
question: AskUserQuestion;
/** True when at least one option was selected or "other" text was given. */
/** True when at least one option was selected or custom text was given. */
answered: boolean;
values: string[];
otherText?: string;