mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
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:
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") });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -369,6 +369,7 @@ export const ar: TranslationResources = {
|
||||
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
|
||||
submit: "يُقدِّم",
|
||||
next: "التالي",
|
||||
answerPlaceholder: "اكتب إجابتك...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -374,6 +374,7 @@ export const ja: TranslationResources = {
|
||||
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
|
||||
submit: "送信",
|
||||
next: "次へ",
|
||||
answerPlaceholder: "回答を入力...",
|
||||
|
||||
@@ -370,6 +370,7 @@ export const ko: TranslationResources = {
|
||||
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
|
||||
submit: "제출",
|
||||
next: "다음",
|
||||
answerPlaceholder: "답변을 입력하세요...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -373,6 +373,7 @@ export const ru: TranslationResources = {
|
||||
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
|
||||
},
|
||||
question: {
|
||||
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
|
||||
submit: "Отправить",
|
||||
next: "Далее",
|
||||
answerPlaceholder: "Введите ответ...",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user