fix(app): advance multi-question prompts step by step (#1462)

This commit is contained in:
Mohamed Boudra
2026-06-11 03:45:32 +00:00
committed by GitHub
parent 6db285b1d9
commit 2828dce65f
5 changed files with 270 additions and 37 deletions
+41
View File
@@ -44,6 +44,18 @@ export async function openQuestion(
.click(); .click();
} }
export async function expectQuestionNavigationEnabled(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await expect(
page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toBeEnabled();
}
export async function fillQuestionAnswer( export async function fillQuestionAnswer(
page: Page, page: Page,
input: { question: string; answer: string }, input: { question: string; answer: string },
@@ -59,3 +71,32 @@ export async function submitQuestionAnswers(page: Page): Promise<void> {
await page.getByTestId("question-form-primary-action").click(); await page.getByTestId("question-form-primary-action").click();
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 }); await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
} }
export async function expectQuestionPrimaryActionEnabled(page: Page, label: string): Promise<void> {
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: label }),
).toBeEnabled();
}
export async function expectQuestionPrimaryActionDisabled(
page: Page,
label: string,
): Promise<void> {
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: label }),
).toBeDisabled();
}
export async function expectQuestionDismissEnabled(page: Page): Promise<void> {
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: "Dismiss" }),
).toBeEnabled();
}
export async function continueToNextQuestion(page: Page): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: "Next" })
.click();
}
@@ -2,9 +2,14 @@ import { test } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { import {
chooseQuestionOption, chooseQuestionOption,
continueToNextQuestion,
expectCurrentQuestion, expectCurrentQuestion,
expectQuestionDismissEnabled,
expectQuestionHidden, expectQuestionHidden,
expectQuestionNavigationEnabled,
expectQuestionOptionSelected, expectQuestionOptionSelected,
expectQuestionPrimaryActionDisabled,
expectQuestionPrimaryActionEnabled,
fillQuestionAnswer, fillQuestionAnswer,
openQuestion, openQuestion,
submitQuestionAnswers, submitQuestionAnswers,
@@ -15,6 +20,8 @@ const TOTAL_QUESTIONS = 3;
const SURFACE_QUESTION = "Which surface should this apply to?"; const SURFACE_QUESTION = "Which surface should this apply to?";
const ROLLOUT_QUESTION = "Which rollout should we use?"; const ROLLOUT_QUESTION = "Which rollout should we use?";
const SUCCESS_QUESTION = "What success criteria should we use?"; const SUCCESS_QUESTION = "What success criteria should we use?";
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
test.describe("Question prompt pagination", () => { test.describe("Question prompt pagination", () => {
test("shows one question at a time with numbered navigation", async ({ page }) => { test("shows one question at a time with numbered navigation", async ({ page }) => {
@@ -70,4 +77,51 @@ test.describe("Question prompt pagination", () => {
await session.cleanup(); await session.cleanup();
} }
}); });
test("free-write questions use Next before final Submit", async ({ page }) => {
test.setTimeout(180_000);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-free-write-",
title: "Question free-write e2e",
initialPrompt: "Emit synthetic questions: two free-write questions.",
});
try {
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
await expectCurrentQuestion(page, {
index: 1,
total: 2,
question: REPO_URL_QUESTION,
});
await fillQuestionAnswer(page, {
question: REPO_URL_QUESTION,
answer: "git@github.com:user/private-repo.git",
});
await expectQuestionPrimaryActionEnabled(page, "Next");
await expectQuestionDismissEnabled(page);
await expectQuestionNavigationEnabled(page, { index: 2, total: 2 });
await continueToNextQuestion(page);
await expectCurrentQuestion(page, {
index: 2,
total: 2,
question: COMMIT_MESSAGE_QUESTION,
});
await expectQuestionPrimaryActionDisabled(page, "Submit");
await fillQuestionAnswer(page, {
question: COMMIT_MESSAGE_QUESTION,
answer: "Initialize private repo",
});
await expectQuestionPrimaryActionEnabled(page, "Submit");
await submitQuestionAnswers(page);
} finally {
await session.cleanup();
}
});
}); });
@@ -16,6 +16,7 @@ import { isWeb } from "@/constants/platform";
import { import {
areQuestionsAnswered, areQuestionsAnswered,
buildQuestionFormAnswers, buildQuestionFormAnswers,
isQuestionAnswered,
parseQuestionFormQuestions, parseQuestionFormQuestions,
questionShowsTextInput, questionShowsTextInput,
resolveDismissLabel, resolveDismissLabel,
@@ -296,6 +297,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
? Math.min(activeQuestionIndex, questions.length - 1) ? Math.min(activeQuestionIndex, questions.length - 1)
: 0; : 0;
const activeQuestion = questions?.[resolvedActiveQuestionIndex]; const activeQuestion = questions?.[resolvedActiveQuestionIndex];
const activeQuestionAnswered = activeQuestion
? isQuestionAnswered(activeQuestion, resolvedActiveQuestionIndex, selections, otherTexts)
: false;
const isLastQuestion = questions ? resolvedActiveQuestionIndex === questions.length - 1 : true;
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return; if (!questions || !allAnswered || isResponding) return;
@@ -340,6 +345,15 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
setActiveQuestionIndex(index); setActiveQuestionIndex(index);
}, []); }, []);
const handlePrimaryAction = useCallback(() => {
if (!isLastQuestion) {
if (!activeQuestionAnswered || isResponding) return;
setActiveQuestionIndex((index) => Math.min(index + 1, (questions?.length ?? 1) - 1));
return;
}
handleSubmit();
}, [activeQuestionAnswered, handleSubmit, isLastQuestion, isResponding, questions?.length]);
const dismissButtonStyle = useCallback( const dismissButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton, styles.actionButton,
@@ -352,18 +366,19 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent], [theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent],
); );
const submitDisabled = !allAnswered || isResponding; const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered);
const primaryActionLabel = isLastQuestion ? "Submit" : "Next";
const submitButtonStyle = useCallback( const submitButtonStyle = useCallback(
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton, styles.actionButton,
{ {
backgroundColor: theme.colors.accent, backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent, borderColor: theme.colors.accent,
opacity: submitDisabled ? 0.5 : 1, opacity: primaryDisabled ? 0.5 : 1,
}, },
pressed && !submitDisabled ? styles.optionItemPressed : null, pressed && !primaryDisabled ? styles.optionItemPressed : null,
], ],
[submitDisabled, theme.colors.accent], [primaryDisabled, theme.colors.accent],
); );
const containerStyle = useMemo( const containerStyle = useMemo(
@@ -458,7 +473,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
placeholder={getQuestionInputPlaceholder(activeQuestion)} placeholder={getQuestionInputPlaceholder(activeQuestion)}
isResponding={isResponding} isResponding={isResponding}
onChange={setOtherText} onChange={setOtherText}
onSubmit={handleSubmit} onSubmit={handlePrimaryAction}
/> />
) : null} ) : null}
</View> </View>
@@ -485,10 +500,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
<Pressable <Pressable
style={submitButtonStyle} style={submitButtonStyle}
onPress={handleSubmit} onPress={handlePrimaryAction}
disabled={submitDisabled} disabled={primaryDisabled}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Submit" accessibilityLabel={primaryActionLabel}
testID="question-form-primary-action" testID="question-form-primary-action"
> >
{respondingAction === "submit" ? ( {respondingAction === "submit" ? (
@@ -496,7 +511,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
) : ( ) : (
<View style={styles.actionContent}> <View style={styles.actionContent}>
<Check size={14} color={submitActionTextColor} /> <Check size={14} color={submitActionTextColor} />
<Text style={submitActionTextStyle}>Submit</Text> <Text style={submitActionTextStyle}>{primaryActionLabel}</Text>
</View> </View>
)} )}
</Pressable> </Pressable>
@@ -11,6 +11,20 @@ import {
MockLoadTestAgentClient, MockLoadTestAgentClient,
} from "./mock-load-test-agent.js"; } from "./mock-load-test-agent.js";
type PermissionRequestedEvent = Extract<AgentStreamEvent, { type: "permission_requested" }>;
function expectSinglePermissionRequest(events: AgentStreamEvent[]): PermissionRequestedEvent {
const permissions = events.filter(
(event): event is PermissionRequestedEvent => event.type === "permission_requested",
);
expect(permissions).toHaveLength(1);
const [permission] = permissions;
if (!permission) {
throw new Error("permission request missing");
}
return permission;
}
describe("MockLoadTestAgentClient", () => { describe("MockLoadTestAgentClient", () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
@@ -131,6 +145,60 @@ describe("MockLoadTestAgentClient", () => {
expect(events).toHaveLength(eventCountAfterInterrupt); expect(events).toHaveLength(eventCountAfterInterrupt);
}); });
test("emits the free-write question scenario selected by prompt", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();
const session = await client.createSession({
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
});
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
const resultPromise = session.run("Emit synthetic questions: two free-write questions.");
await vi.advanceTimersByTimeAsync(0);
const permission = expectSinglePermissionRequest(events);
expect(permission.request).toMatchObject({
provider: "mock",
name: "MockQuestions",
kind: "question",
input: {
questions: [
{
question: "What is the GitHub private repo URL to push to?",
header: "repoUrl",
options: [],
multiSelect: false,
},
{
question: "What should the first commit message be?",
header: "commitMessage",
options: [],
multiSelect: false,
},
],
},
});
await session.respondToPermission(permission.request.id, {
behavior: "allow",
updatedInput: {
answers: {
repoUrl: "git@github.com:user/private-repo.git",
commitMessage: "Initialize private repo",
},
},
});
await expect(resultPromise).resolves.toMatchObject({
sessionId: session.id,
finalText: "Synthetic questions resolved",
canceled: false,
});
unsubscribe();
});
test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => { test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-")); const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-"));
@@ -127,12 +127,80 @@ interface AgentStreamStressRequest {
coalesced: boolean; coalesced: boolean;
} }
interface MockQuestionOption {
label: string;
description?: string;
}
interface MockQuestionPromptQuestion {
question: string;
header: string;
options: MockQuestionOption[];
multiSelect: boolean;
allowOther?: boolean;
allowEmpty?: boolean;
placeholder?: string;
dismissLabel?: string;
}
interface MockQuestionPromptRequest {
questions: MockQuestionPromptQuestion[];
}
function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean { function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean {
return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt)); return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt));
} }
function shouldEmitQuestionPrompt(prompt: AgentPromptInput): boolean { function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null {
return /emit\s+(?:a\s+)?synthetic\s+questions?/i.test(promptToText(prompt)); const text = promptToText(prompt);
if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) {
return null;
}
if (/free[-\s]?write|freeform|text[-\s]?only/i.test(text)) {
return {
questions: [
{
question: "What is the GitHub private repo URL to push to?",
header: "repoUrl",
options: [],
multiSelect: false,
placeholder: "git@github.com:user/repo.git",
},
{
question: "What should the first commit message be?",
header: "commitMessage",
options: [],
multiSelect: false,
placeholder: "Initial commit",
},
],
};
}
return {
questions: [
{
question: "Which surface should this apply to?",
header: "surface",
options: [{ label: "App" }, { label: "Desktop" }],
multiSelect: false,
},
{
question: "Which rollout should we use?",
header: "rollout",
options: [{ label: "Immediately" }, { label: "Behind feature flag" }],
multiSelect: false,
},
{
question: "What success criteria should we use?",
header: "success",
options: [],
multiSelect: false,
placeholder: "Describe success...",
},
],
};
} }
function resolveModelProfile(modelId: string | null | undefined): { function resolveModelProfile(modelId: string | null | undefined): {
@@ -536,10 +604,11 @@ export class MockLoadTestAgentSession implements AgentSession {
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt); const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
const stress = parseAgentStreamStressPrompt(prompt); const stress = parseAgentStreamStressPrompt(prompt);
const questionPrompt = parseMockQuestionPrompt(prompt);
if (shouldEmitPlanApprovalPrompt(prompt)) { if (shouldEmitPlanApprovalPrompt(prompt)) {
this.schedulePlanApprovalTurn(turn); this.schedulePlanApprovalTurn(turn);
} else if (shouldEmitQuestionPrompt(prompt)) { } else if (questionPrompt) {
this.scheduleQuestionPromptTurn(turn); this.scheduleQuestionPromptTurn(turn, questionPrompt);
} else if (largePayload) { } else if (largePayload) {
this.scheduleLargePayloadTurn(turn, largePayload); this.scheduleLargePayloadTurn(turn, largePayload);
} else if (stress) { } else if (stress) {
@@ -712,9 +781,12 @@ export class MockLoadTestAgentSession implements AgentSession {
turn.timer.unref?.(); turn.timer.unref?.();
} }
private scheduleQuestionPromptTurn(turn: ActiveTurn): void { private scheduleQuestionPromptTurn(
turn: ActiveTurn,
questionPrompt: MockQuestionPromptRequest,
): void {
turn.timer = setTimeout(() => { turn.timer = setTimeout(() => {
this.emitQuestionPromptTurn(turn); this.emitQuestionPromptTurn(turn, questionPrompt);
}, 0); }, 0);
turn.timer.unref?.(); turn.timer.unref?.();
} }
@@ -771,7 +843,10 @@ export class MockLoadTestAgentSession implements AgentSession {
}); });
} }
private emitQuestionPromptTurn(turn: ActiveTurn): void { private emitQuestionPromptTurn(
turn: ActiveTurn,
questionPrompt: MockQuestionPromptRequest,
): void {
if (this.activeTurn !== turn) { if (this.activeTurn !== turn) {
return; return;
} }
@@ -790,27 +865,7 @@ export class MockLoadTestAgentSession implements AgentSession {
kind: "question", kind: "question",
title: "Questions", title: "Questions",
input: { input: {
questions: [ questions: questionPrompt.questions,
{
question: "Which surface should this apply to?",
header: "surface",
options: [{ label: "App" }, { label: "Desktop" }],
multiSelect: false,
},
{
question: "Which rollout should we use?",
header: "rollout",
options: [{ label: "Immediately" }, { label: "Behind feature flag" }],
multiSelect: false,
},
{
question: "What success criteria should we use?",
header: "success",
options: [],
multiSelect: false,
placeholder: "Describe success...",
},
],
}, },
metadata: { metadata: {
source: "mock_questions", source: "mock_questions",