fix(app): recover failed question responses (#7098)

* test: reproduce failed question response recovery

* fix: show question response failures and allow retry
This commit is contained in:
xzthemeofsss
2026-10-16 22:12:05 +02:00
committed by GitHub
parent 05b3745dcc
commit d152f1529f
16 changed files with 314 additions and 7 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,5 +1,16 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { killProcessTree } from "../support/helpers/spawn-node";
import {
buildAgentRoute,
openAgentRoute,
seedMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import {
chooseQuestionOption,
continueToNextQuestion,
@@ -130,3 +141,204 @@ test.describe("Question prompt pagination", () => {
}
});
});
const questionDaemonMessage = z.discriminatedUnion("type", [
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
z.object({
type: z.literal("attempt"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
}),
z.object({
type: z.literal("resolved"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
pendingRequestIds: z.array(z.string()),
}),
z.object({ type: z.literal("error"), error: z.string() }),
]);
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
interface QuestionRetryControl {
attempts: QuestionResponseAttempt[];
resolutions: QuestionResolution[];
releaseFirstResponse(): void;
}
const retryTest = test.extend<{
questionClient: QuestionRetryControl;
}>({
questionClient: async ({ page }, provide, testInfo) => {
const serverDir = path.resolve(__dirname, "../../../server");
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
cwd: serverDir,
execArgv: ["--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
const output: string[] = [];
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
const attempts: QuestionResponseAttempt[] = [];
const resolutions: QuestionResolution[] = [];
child.on("message", (message: unknown) => {
const parsed = questionDaemonMessage.parse(message);
switch (parsed.type) {
case "ready":
ready.resolve(parsed);
break;
case "attempt":
attempts.push(parsed);
break;
case "resolved":
resolutions.push(parsed);
break;
case "error":
ready.reject(new Error(parsed.error));
break;
}
});
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
const timeout = setTimeout(
() => ready.reject(new Error("Question daemon startup timed out")),
20_000,
);
try {
const daemon = await ready.promise;
clearTimeout(timeout);
const session = await seedMockAgentWorkspace({
port: daemon.port,
repoPrefix: "question-retry-",
title: "Question response retry",
initialPrompt: "Emit synthetic questions.",
});
try {
await page.goto("/", { waitUntil: "commit" });
const serverId = daemon.serverId;
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
label: "Question retry host",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
waitUntil: "commit",
});
await waitForQuestionPrompt(page, 120_000);
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
} finally {
await session.cleanup();
}
} finally {
clearTimeout(timeout);
await killProcessTree(child);
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("question-daemon", {
body: output.join(""),
contentType: "text/plain",
});
}
}
},
});
retryTest(
"failed question submission preserves answers and allows retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
});
await card.getByRole("button", { name: "Submit" }).click();
await expect(card.getByRole("progressbar")).toBeVisible();
await expectQuestionPrimaryActionDisabled(page, "Submit");
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
await expect(card.getByRole("tab").first()).toBeDisabled();
await expect(card.getByRole("textbox")).not.toBeEditable();
questionClient.releaseFirstResponse();
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
await expectQuestionDismissEnabled(page);
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "Behind feature flag");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue(
"Preserve my answers after a failed submission.",
);
await submitQuestionAnswers(page);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toMatchObject({
behavior: "allow",
updatedInput: {
answers: {
surface: "App",
rollout: "Behind feature flag",
success: "Preserve my answers after a failed submission.",
},
},
});
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
},
);
retryTest(
"failed question dismissal allows an immediate retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
});
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
await expectQuestionDismissEnabled(page);
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
await expect(card.getByRole("alert")).toBeVisible();
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card).toHaveCount(0);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toEqual({
behavior: "deny",
message: "Dismissed by user",
});
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
},
);
+6
View File
@@ -1531,11 +1531,17 @@ function PermissionRequestCard({
);
if (request.kind === "question") {
const responseFailed =
permissionMutation.isError &&
permissionMutation.variables?.agentId === permission.agentId &&
permissionMutation.variables?.requestId === permission.request.id;
const responseError = responseFailed ? t("message.question.responseFailed") : null;
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
responseError={responseError}
/>
);
}
@@ -58,6 +58,7 @@ function mountCard(question: Record<string, unknown>) {
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
responseError={null}
/>,
),
);
@@ -1,3 +1,4 @@
import { Alert } from "@/components/ui/alert";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
@@ -27,6 +28,7 @@ interface QuestionFormCardProps {
permission: PendingPermission;
onRespond: (response: AgentPermissionResponse) => void;
isResponding: boolean;
responseError: string | null;
}
const IS_WEB = isWeb;
@@ -339,7 +341,12 @@ function QuestionOtherInput({
);
}
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
export function QuestionFormCard({
permission,
onRespond,
isResponding,
responseError,
}: QuestionFormCardProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
@@ -438,7 +445,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
]);
const handleDeny = useCallback(() => {
if (!questions) return;
if (!questions || isResponding) return;
setRespondingAction("dismiss");
if (shouldSubmitEmptyOnDismiss(questions)) {
onRespond({
@@ -454,7 +461,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
behavior: "deny",
message: "Dismissed by user",
});
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
}, [questions, isResponding, onRespond, otherTexts, permission.request.input, selections]);
const handleSelectQuestion = useCallback((index: number) => {
setActiveQuestionIndex(index);
@@ -487,6 +494,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent],
);
const pendingAction = isResponding ? respondingAction : null;
const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered);
const primaryActionLabel = isLastQuestion
? t("message.question.submit")
@@ -550,7 +558,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
const showTextInput = questionShowsTextInput(questions[resolvedActiveQuestionIndex]);
return (
<View style={containerStyle} testID="question-form-card">
@@ -604,6 +612,8 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
</View>
) : null}
{responseError ? <Alert variant="error" description={responseError} size="sm" /> : null}
<View style={actionsContainerStyle}>
<Pressable
style={dismissButtonStyle}
@@ -613,7 +623,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
accessibilityLabel={dismissLabel}
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
{pendingAction === "dismiss" ? (
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
@@ -631,7 +641,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
accessibilityLabel={primaryActionLabel}
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
{pendingAction === "submit" ? (
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
+1
View File
@@ -369,6 +369,7 @@ export const ar: TranslationResources = {
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
},
question: {
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
submit: "يُقدِّم",
next: "التالي",
answerPlaceholder: "اكتب إجابتك...",
+1
View File
@@ -366,6 +366,7 @@ export const en = {
failedRetry: "Dictation failed. Tap retry.",
},
question: {
responseFailed: "Could not send your response. Try again.",
submit: "Submit",
next: "Next",
answerPlaceholder: "Type your answer...",
+1
View File
@@ -374,6 +374,7 @@ export const es: TranslationResources = {
failedRetry: "El dictado falló. Toca reintentar.",
},
question: {
responseFailed: "No se pudo enviar tu respuesta. Inténtalo de nuevo.",
submit: "Entregar",
next: "Siguiente",
answerPlaceholder: "Escribe tu respuesta...",
+1
View File
@@ -374,6 +374,7 @@ export const fr: TranslationResources = {
failedRetry: "La dictée a échoué. Appuyez sur Réessayer.",
},
question: {
responseFailed: "Impossible d’envoyer votre réponse. Réessayez.",
submit: "Envoyer",
next: "Suivant",
answerPlaceholder: "Saisissez votre réponse…",
+1
View File
@@ -374,6 +374,7 @@ export const ja: TranslationResources = {
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
},
question: {
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
submit: "送信",
next: "次へ",
answerPlaceholder: "回答を入力...",
+1
View File
@@ -370,6 +370,7 @@ export const ko: TranslationResources = {
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
},
question: {
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
submit: "제출",
next: "다음",
answerPlaceholder: "답변을 입력하세요...",
+1
View File
@@ -373,6 +373,7 @@ export const ptBR: TranslationResources = {
failedRetry: "Falha no ditado. Toque para tentar novamente.",
},
question: {
responseFailed: "Não foi possível enviar sua resposta. Tente novamente.",
submit: "Enviar",
next: "Próximo",
answerPlaceholder: "Digite sua resposta...",
+1
View File
@@ -373,6 +373,7 @@ export const ru: TranslationResources = {
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
},
question: {
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
submit: "Отправить",
next: "Далее",
answerPlaceholder: "Введите ответ...",
+1
View File
@@ -369,6 +369,7 @@ export const zhCN: TranslationResources = {
failedRetry: "听写失败。点按重试。",
},
question: {
responseFailed: "未能发送你的回复,请重试。",
submit: "提交",
next: "下一步",
answerPlaceholder: "输入你的回答...",
@@ -0,0 +1,69 @@
import { randomUUID } from "node:crypto";
import {
MockLoadTestAgentClient,
MockLoadTestAgentSession,
} from "../agent/providers/mock-load-test-agent.js";
import { createTestPaseoDaemon } from "./paseo-daemon.js";
const firstResponse = Promise.withResolvers<void>();
class RetryQuestionSession extends MockLoadTestAgentSession {
private attempts = 0;
override async respondToPermission(
requestId: string,
response: Parameters<MockLoadTestAgentSession["respondToPermission"]>[1],
) {
this.attempts += 1;
process.send?.({ type: "attempt", requestId, response, sessionId: this.id });
if (this.attempts === 1) {
await firstResponse.promise;
throw new Error("Synthetic question response failure");
}
return super.respondToPermission(requestId, response);
}
}
class RetryQuestionClient extends MockLoadTestAgentClient {
override async createSession(config: Parameters<MockLoadTestAgentClient["createSession"]>[0]) {
const session = new RetryQuestionSession({ config, sessionId: randomUUID() });
session.subscribe((event) => {
if (event.type === "permission_resolved") {
process.send?.({
type: "resolved",
requestId: event.requestId,
response: event.resolution,
sessionId: session.id,
pendingRequestIds: session.getPendingPermissions().map((request) => request.id),
});
}
});
return session;
}
}
async function main(): Promise<void> {
const daemon = await createTestPaseoDaemon({
agentClients: { mock: new RetryQuestionClient() },
isDev: true,
mcpEnabled: false,
corsAllowedOrigins: [`http://localhost:${process.env.E2E_METRO_PORT}`],
});
process.on("message", (message: unknown) => {
if (message === "release") firstResponse.resolve();
});
const shutdown = async () => {
firstResponse.resolve();
await daemon.close();
process.exit(0);
};
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
process.once("disconnect", () => void shutdown());
process.send?.({ type: "ready", port: daemon.port, serverId: daemon.daemon.getServerId() });
}
void main().catch((error: unknown) => {
process.send?.({ type: "error", error: error instanceof Error ? error.message : String(error) });
process.exit(1);
});