fix: surface unanswered ask_user questions

This commit is contained in:
Erik Kotsiuba
2026-08-12 14:18:22 +02:00
parent 852e625d86
commit ce230db275
3 changed files with 87 additions and 3 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ This document defines the stable external behavior. It does not explain internal
- semantically invalid payloads that reach tool execution return `error.kind === "invalid_input"` with structured `issues` and a transcript-friendly `Invalid ask_user payload:` message; their rendered status is `Invalid tool payload` - semantically invalid payloads that reach tool execution return `error.kind === "invalid_input"` with structured `issues` and a transcript-friendly `Invalid ask_user payload:` message; their rendered status is `Invalid tool payload`
- payloads missing schema-required fields fail Pi's schema validation before tool execution and use Pi's standard tool-error result without structured `details` - payloads missing schema-required fields fail Pi's schema validation before tool execution and use Pi's standard tool-error result without structured `details`
- `mode: "submit"` is normal completion; `mode: "elaborate"` means the user asked the agent to continue with follow-up clarification based on notes - `mode: "submit"` is normal completion; `mode: "elaborate"` means the user asked the agent to continue with follow-up clarification based on notes
- unanswered questions are omitted from `answers` - unanswered questions are omitted from `answers`, but non-cancelled submitted result text includes `<label>: (no answer)` in summary mode and `? <label>: (no answer)` in transcript rendering
- in `mode: "elaborate"`, `answers` contains only committed answers; note-only entries move to `elaboration.items` - in `mode: "elaborate"`, `answers` contains only committed answers; note-only entries move to `elaboration.items`
- `continuation.strategy === "refine_only"` means the next ask should refine the current flow rather than restart it - `continuation.strategy === "refine_only"` means the next ask should refine the current flow rather than restart it
- `continuation.preservedAnswers` contains previously committed answers that should be kept as context and not re-asked - `continuation.preservedAnswers` contains previously committed answers that should be kept as context and not re-asked
+10
View File
@@ -13,6 +13,7 @@ export function formatResultLines(
for (const question of result.questions) { for (const question of result.questions) {
const answer = result.answers[question.id]; const answer = result.answers[question.id];
if (!answer) { if (!answer) {
lines.push(formatUnansweredLine(question.label, options.mode));
continue; continue;
} }
@@ -44,6 +45,15 @@ export function formatResultLines(
return lines; return lines;
} }
function formatUnansweredLine(
questionLabel: string,
mode: "summary" | "render"
): string {
return mode === "summary"
? `${questionLabel}: (no answer)`
: `? ${questionLabel}: (no answer)`;
}
function formatAnswerLine( function formatAnswerLine(
questionLabel: string, questionLabel: string,
answer: AskResult["answers"][string], answer: AskResult["answers"][string],
+76 -2
View File
@@ -5,6 +5,7 @@ import { createInitialState } from "../src/state/create.ts";
import { summarizeResult, toAskResult } from "../src/state/result.ts"; import { summarizeResult, toAskResult } from "../src/state/result.ts";
import { import {
applyNumberShortcut, applyNumberShortcut,
confirmCurrentSelection,
enterOptionNoteMode, enterOptionNoteMode,
enterQuestionNoteMode, enterQuestionNoteMode,
moveOption, moveOption,
@@ -36,6 +37,71 @@ test("summarizeResult formats selected answers", () => {
assert.equal(renderResultText(result), "✓ Style: Rich"); assert.equal(renderResultText(result), "✓ Style: Rich");
}); });
test("submitted results surface unanswered questions without serializing answers", () => {
let state = createInitialState({
questions: [
{
id: "architecture",
label: "Architecture",
prompt: "How should we wire secrets?",
options: [{ value: "encrypted", label: "Encrypted" }],
},
{
id: "sources",
label: "Secret sources",
prompt: "Where do secrets come from?",
type: "multi",
options: [{ value: "env", label: ".env file" }],
},
],
});
state = applyNumberShortcut(state, 1);
state = confirmCurrentSelection(state);
state = confirmCurrentSelection(state);
const result = toAskResult(state);
assert.equal(result.answers.sources, undefined);
assert.equal(
summarizeResult(result),
"Architecture: Encrypted\nSecret sources: (no answer)"
);
assert.equal(
renderResultText(result),
"✓ Architecture: Encrypted\n? Secret sources: (no answer)"
);
});
test("submitted results surface every question when all are unanswered", () => {
const result = {
cancelled: false,
mode: "submit" as const,
questions: [
{
id: "scope",
label: "Scope",
prompt: "What scope?",
type: "single" as const,
},
{
id: "tests",
label: "Tests",
prompt: "Which tests?",
type: "multi" as const,
},
],
answers: {},
};
assert.equal(
summarizeResult(result),
"Scope: (no answer)\nTests: (no answer)"
);
assert.equal(
renderResultText(result),
"? Scope: (no answer)\n? Tests: (no answer)"
);
});
test("single questions presented as multi preserve requested type in results", () => { test("single questions presented as multi preserve requested type in results", () => {
let state = createInitialState( let state = createInitialState(
{ {
@@ -584,14 +650,22 @@ test("invalid tool payload result is attributed to the tool call", () => {
assert.equal(renderResultText(result), "Invalid tool payload"); assert.equal(renderResultText(result), "Invalid tool payload");
}); });
test("cancelled result renders as cancelled", () => { test("cancelled result hides unanswered questions", () => {
const result = { const result = {
title: "Interview", title: "Interview",
cancelled: true, cancelled: true,
mode: "submit" as const, mode: "submit" as const,
questions: [], questions: [
{
id: "scope",
label: "Scope",
prompt: "What scope?",
type: "single" as const,
},
],
answers: {}, answers: {},
}; };
assert.equal(summarizeResult(result), "User cancelled the ask flow");
assert.equal(renderResultText(result), "Cancelled"); assert.equal(renderResultText(result), "Cancelled");
}); });