From db5c12f67f726297d14e75318114fcd3eca60895 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 10:12:18 +0800 Subject: [PATCH] fix: preserve dynamic action receipts --- ...se-birth-time-automatic-journey-effects.ts | 26 ++--- .../hooks/use-birth-time-guided-journey.ts | 93 ++++++++++++------ .../birth-time-guided-draft-confirmation.ts | 5 - .../birth-time-guided-effect-coordinator.ts | 41 ++++++++ frontend/tests/birth-time-dynamic-api.test.ts | 6 +- frontend/tests/birth-time-guide-flow.test.ts | 3 +- .../birth-time-guided-review-fixes.test.ts | 1 - .../birth-time-stable-action-identity.test.ts | 94 +++++++++++++++++++ 8 files changed, 217 insertions(+), 52 deletions(-) create mode 100644 frontend/tests/birth-time-stable-action-identity.test.ts diff --git a/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts b/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts index 5766fb9b..6cb311b4 100644 --- a/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts +++ b/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts @@ -11,13 +11,16 @@ import type { JourneyClientResponse } from "@/lib/birth-time-journey-client"; import { createIdentityRequestCache, publishCurrentJourney, + runStableJourneyAction, scheduleCancellableStart, } from "@/lib/birth-time-guided-effect-coordinator"; +import type { StableActionIdentityRegistry } from "@/lib/birth-time-guided-effect-coordinator"; import { runBirthTimeScoringPoll, scoringPollDelay } from "@/lib/birth-time-guided-polling"; type AutomaticEffectsInput = { readonly journey: JourneyClientResponse | null; readonly latest: { current: JourneyClientResponse | null }; + readonly actionRegistry: StableActionIdentityRegistry; readonly preview: boolean; readonly pollRun: number; readonly generationRun: number; @@ -25,9 +28,6 @@ type AutomaticEffectsInput = { readonly setError: (message: string) => void; }; -const guideRequests = createIdentityRequestCache>>(); -const generationRequests = createIdentityRequestCache>>(); - function fallbackQuestion(turn: JourneyClientResponse): string { const action = turn.nextAction; if (turn.journeyProtocol === "dynamic-choice-v2") { @@ -39,7 +39,9 @@ function fallbackQuestion(turn: JourneyClientResponse): string { } export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput): string { - const { generationRun, journey, latest, onJourney, pollRun, preview, setError } = input; + const { actionRegistry, generationRun, journey, latest, onJourney, pollRun, preview, setError } = input; + const [guideRequests] = useState(() => createIdentityRequestCache>>()); + const [generationRequests] = useState(() => createIdentityRequestCache>>()); const [agentQuestion, setAgentQuestion] = useState<{ readonly key: string; readonly text: string } | null>(null); useEffect(() => { @@ -55,7 +57,7 @@ export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput } }).catch(() => { if (active) setAgentQuestion(null); }); return () => { active = false; }; - }, [journey, preview]); + }, [guideRequests, journey, preview]); const generationIdentity = journey?.journeyProtocol === "dynamic-choice-v2" && (journey.nextAction.kind === "generate_dynamic_question" @@ -70,18 +72,20 @@ export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput && turn.nextAction.kind !== "retry_question_generation") return; const expected = turn; const key = `${turn.caseId}:${turn.turnVersion}`; - void generationRequests.run(key, () => generateDynamicBirthTimeQuestion( - turn.caseId, - globalThis.crypto.randomUUID(), - turn.turnVersion, - )).then((next) => { + void generationRequests.run(key, () => runStableJourneyAction(actionRegistry, { + caseId: turn.caseId, + turnVersion: turn.turnVersion, + operation: "generate_dynamic_question", + }, (actionId) => generateDynamicBirthTimeQuestion( + turn.caseId, actionId, turn.turnVersion, + ))).then((next) => { if (publishCurrentJourney({ expected, current: latest.current, next, publish: onJourney })) latest.current = next; }).catch((caught: unknown) => { if (latest.current?.caseId === expected.caseId && latest.current.turnVersion === expected.turnVersion) { setError(caught instanceof Error ? caught.message : "暂时无法生成下一题,请重试。"); } }); - }, [generationIdentity, generationRun, latest, onJourney, preview, setError]); + }, [actionRegistry, generationIdentity, generationRequests, generationRun, latest, onJourney, preview, setError]); const pollIdentity = journey?.nextAction.kind === "score_pending" ? `${journey.caseId}:${journey.turnVersion}:${journey.nextAction.jobId}` diff --git a/frontend/src/hooks/use-birth-time-guided-journey.ts b/frontend/src/hooks/use-birth-time-guided-journey.ts index b810e605..f78a4463 100644 --- a/frontend/src/hooks/use-birth-time-guided-journey.ts +++ b/frontend/src/hooks/use-birth-time-guided-journey.ts @@ -21,7 +21,9 @@ import { import { confirmReviewedBirthTimeDraft } from "@/lib/birth-time-guided-draft-confirmation"; import { claimMutation, + createStableActionIdentityRegistry, publishCurrentJourney, + runStableJourneyAction, } from "@/lib/birth-time-guided-effect-coordinator"; import type { EvidenceDatePrecision } from "@/lib/birth-time-question-planner"; import { useBirthTimeAutomaticJourneyEffects } from "@/hooks/use-birth-time-automatic-journey-effects"; @@ -59,6 +61,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG const { journey, onJourney, onReady, onEditBirthTimeDetails, preview } = input; const latest = useRef(journey); const busy = useRef(false); + const [actionRegistry] = useState(() => createStableActionIdentityRegistry()); const [pending, setPending] = useState(false); const [error, setError] = useState(""); const [pollRun, setPollRun] = useState(0); @@ -105,10 +108,28 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG }); }, [journey, onJourney]); - const actionId = () => globalThis.crypto.randomUUID(); + const stableCommand = ( + caseId: string, + turnVersion: number, + operation: string, + payload: readonly string[], + send: (actionId: string) => Promise, + ) => runStableJourneyAction(actionRegistry, { + caseId, + turnVersion, + operation, + payload, + }, send); + const stableAction = ( + turn: JourneyClientResponse, + operation: string, + payload: readonly string[], + send: (actionId: string) => Promise, + ) => stableCommand(turn.caseId, turn.turnVersion, operation, payload, send); const question = useBirthTimeAutomaticJourneyEffects({ journey, latest, + actionRegistry, preview, pollRun, generationRun, @@ -118,26 +139,36 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG const submitMessage = (message: string) => operate((turn) => { if (preview) return Promise.resolve(turn); - return draftBirthTimeEvidence(turn.caseId, actionId(), turn.turnVersion, message).then((value) => value.turn); + return stableAction(turn, "draft_evidence", [message.trim()], (actionId) => ( + draftBirthTimeEvidence(turn.caseId, actionId, turn.turnVersion, message).then((value) => value.turn) + )); }); const confirmDraft = (precision: EvidenceDatePrecision, date: string) => operate(async (turn, publishIntermediate) => { if (preview) return turn; return confirmReviewedBirthTimeDraft({ turn, precision, date }, { - createActionId: actionId, - revise: reviseBirthTimeEvidenceDraft, + revise: (command) => stableCommand( + command.caseId, command.turnVersion, "revise_evidence_draft", [command.precision, command.date], + (actionId) => reviseBirthTimeEvidenceDraft({ ...command, actionId }), + ), publish: publishIntermediate, - confirm: (command) => confirmBirthTimeEvidenceDraft( - command.caseId, - command.actionId, - command.turnVersion, - command.draftId, + confirm: (command) => stableCommand( + command.caseId, command.turnVersion, "confirm_evidence_draft", [command.draftId], + (actionId) => confirmBirthTimeEvidenceDraft( + command.caseId, actionId, command.turnVersion, command.draftId, + ), ), }); }); - const skip = () => operate((turn) => preview ? Promise.resolve(turn) : skipBirthTimeEvidenceQuestion(turn.caseId, actionId(), turn.turnVersion)); - const pause = () => operate((turn) => preview ? Promise.resolve(turn) : pauseBirthTimeRectification(turn.caseId, actionId(), turn.turnVersion)); + const skip = () => operate((turn) => preview ? Promise.resolve(turn) : stableAction( + turn, "skip_evidence_question", [], + (actionId) => skipBirthTimeEvidenceQuestion(turn.caseId, actionId, turn.turnVersion), + )); + const pause = () => operate((turn) => preview ? Promise.resolve(turn) : stableAction( + turn, "pause_rectification", [], + (actionId) => pauseBirthTimeRectification(turn.caseId, actionId, turn.turnVersion), + )); const resume = () => operate((turn) => preview ? Promise.resolve(turn) : resumeBirthTimeJourney(turn.caseId)); const acknowledgeReady = () => { if (journey?.nextAction.kind === "ready") onReady(journey); @@ -153,33 +184,39 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG ? pollBirthTimeScoring(current.caseId, current.nextAction.jobId) : Promise.resolve(current)); }; - const saveCandidate = (resultId: string) => operate((turn) => preview ? Promise.resolve(turn) : saveGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId: actionId(), turnVersion: turn.turnVersion, resultId })); - const confirmCandidate = (resultId: string, time: string) => operate((turn) => preview ? Promise.resolve(turn) : confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId: actionId(), turnVersion: turn.turnVersion, resultId, time })); + const saveCandidate = (resultId: string) => operate((turn) => preview ? Promise.resolve(turn) : stableAction( + turn, "save_guided_candidate", [resultId], + (actionId) => saveGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId }), + )); + const confirmCandidate = (resultId: string, time: string) => operate((turn) => preview ? Promise.resolve(turn) : stableAction( + turn, "confirm_guided_candidate", [resultId, time], + (actionId) => confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }), + )); const selectOption = (optionId: string) => operate((turn) => { if (preview || turn.journeyProtocol !== "dynamic-choice-v2" || turn.nextAction.kind !== "ask_dynamic_choice") return Promise.resolve(turn); - return answerDynamicBirthTimeChoice({ - caseId: turn.caseId, - actionId: actionId(), - turnVersion: turn.turnVersion, - questionId: turn.nextAction.question.questionId, - optionId, - }); + const questionId = turn.nextAction.question.questionId; + return stableAction(turn, "answer_dynamic_choice", [questionId, optionId], (actionId) => ( + answerDynamicBirthTimeChoice({ + caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, questionId, optionId, + }) + )); }); const submitUnmatchedContext = (note: string) => operate((turn) => { if (preview || turn.journeyProtocol !== "dynamic-choice-v2" || turn.nextAction.kind !== "clarify_unmatched_answer") return Promise.resolve(turn); - return reframeUnmatchedBirthTimeAnswer({ - caseId: turn.caseId, - actionId: actionId(), - turnVersion: turn.turnVersion, - questionId: turn.nextAction.questionId, - note, - }); + const questionId = turn.nextAction.questionId; + return stableAction(turn, "reframe_unmatched", [questionId, note.trim()], (actionId) => ( + reframeUnmatchedBirthTimeAnswer({ + caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, questionId, note, + }) + )); }); const finish = () => operate((turn) => preview ? Promise.resolve(turn) - : finishBirthTimeRectification(turn.caseId, actionId(), turn.turnVersion)); + : stableAction(turn, "finish_rectification", [], (actionId) => ( + finishBirthTimeRectification(turn.caseId, actionId, turn.turnVersion) + ))); const retryQuestionGeneration = () => { if (journey?.journeyProtocol !== "dynamic-choice-v2") return; if (journey.nextAction.kind !== "generate_dynamic_question" diff --git a/frontend/src/lib/birth-time-guided-draft-confirmation.ts b/frontend/src/lib/birth-time-guided-draft-confirmation.ts index ff18d3c5..4692d0e3 100644 --- a/frontend/src/lib/birth-time-guided-draft-confirmation.ts +++ b/frontend/src/lib/birth-time-guided-draft-confirmation.ts @@ -10,7 +10,6 @@ type ConfirmDraftInput = { type RevisionCommand = { readonly caseId: string; - readonly actionId: string; readonly turnVersion: number; readonly precision: EvidenceDatePrecision; readonly date: string; @@ -18,13 +17,11 @@ type RevisionCommand = { type ConfirmationCommand = { readonly caseId: string; - readonly actionId: string; readonly turnVersion: number; readonly draftId: string; }; type ConfirmDraftPorts = { - readonly createActionId: () => string; readonly revise: (command: RevisionCommand) => Promise; readonly publish: (turn: JourneyClientResponse) => void; readonly confirm: (command: ConfirmationCommand) => Promise; @@ -50,7 +47,6 @@ export async function confirmReviewedBirthTimeDraft( ? input.turn : await ports.revise({ caseId: input.turn.caseId, - actionId: ports.createActionId(), turnVersion: input.turn.turnVersion, precision: parsed.precision, date: parsed.date, @@ -60,7 +56,6 @@ export async function confirmReviewedBirthTimeDraft( if (!currentDraft) throw new GuidedDraftConfirmationError("经历草稿已经变化,请使用最新内容。"); return ports.confirm({ caseId: revised.caseId, - actionId: ports.createActionId(), turnVersion: revised.turnVersion, draftId: currentDraft.draftId, }); diff --git a/frontend/src/lib/birth-time-guided-effect-coordinator.ts b/frontend/src/lib/birth-time-guided-effect-coordinator.ts index 60e1d85f..4a36752b 100644 --- a/frontend/src/lib/birth-time-guided-effect-coordinator.ts +++ b/frontend/src/lib/birth-time-guided-effect-coordinator.ts @@ -1,5 +1,21 @@ import type { JourneyClientResponse } from "./birth-time-journey-response-schema.ts"; +type StableActionIdentityInput = { + readonly caseId: string; + readonly turnVersion: number; + readonly operation: string; + readonly payload?: readonly string[]; +}; + +export function stableActionIdentity(input: StableActionIdentityInput): string { + return JSON.stringify([ + input.caseId, + input.turnVersion, + input.operation, + ...(input.payload ?? []), + ]); +} + export function createIdentityRequestCache() { const requests = new Map>(); return { @@ -16,6 +32,31 @@ export function createIdentityRequestCache() { }; } +export function createStableActionIdentityRegistry( + createId: () => string = () => globalThis.crypto.randomUUID(), +) { + const actionIds = new Map(); + return { + async run(identity: string, operation: (actionId: string) => Promise): Promise { + const actionId = actionIds.get(identity) ?? createId(); + actionIds.set(identity, actionId); + const result = await operation(actionId); + if (actionIds.get(identity) === actionId) actionIds.delete(identity); + return result; + }, + }; +} + +export type StableActionIdentityRegistry = ReturnType; + +export function runStableJourneyAction( + registry: StableActionIdentityRegistry, + identity: StableActionIdentityInput, + operation: (actionId: string) => Promise, +): Promise { + return registry.run(stableActionIdentity(identity), operation); +} + export function scheduleCancellableStart(start: () => void): () => void { const timer = globalThis.setTimeout(start, 0); return () => globalThis.clearTimeout(timer); diff --git a/frontend/tests/birth-time-dynamic-api.test.ts b/frontend/tests/birth-time-dynamic-api.test.ts index 6d4f5862..7ab09277 100644 --- a/frontend/tests/birth-time-dynamic-api.test.ts +++ b/frontend/tests/birth-time-dynamic-api.test.ts @@ -102,13 +102,9 @@ test("dynamic response parsing rejects private legacy and candidate payloads", ( for (const payload of privatePayloads) assert.throws(() => parseJourneyResponse(payload)); }); -test("dynamic routes authenticate before parsing and dispatch scoped methods", () => { +test("dynamic route boundaries authenticate before parsing user input", () => { const journeyRoute = readFileSync(new URL("../src/app/api/birth-time-journey/route.ts", import.meta.url), "utf8"); const guideRoute = readFileSync(new URL("../src/app/api/birth-time-guide/route.ts", import.meta.url), "utf8"); assert.ok(journeyRoute.indexOf("auth.getUser") < journeyRoute.indexOf("requestPayload(request)")); assert.ok(guideRoute.indexOf("auth.getUser") < guideRoute.indexOf("requestPayload(request)")); - assert.match(journeyRoute, /answerDynamicChoice/); - assert.match(journeyRoute, /pollDynamicScoringJob/); - assert.match(guideRoute, /generateQuestion/); - assert.match(guideRoute, /submitUnmatchedContext/); }); diff --git a/frontend/tests/birth-time-guide-flow.test.ts b/frontend/tests/birth-time-guide-flow.test.ts index 8edb88de..4c1d4497 100644 --- a/frontend/tests/birth-time-guide-flow.test.ts +++ b/frontend/tests/birth-time-guide-flow.test.ts @@ -36,10 +36,9 @@ test("draft review is explicit, domain locked, and incomplete confirmation stays assert.match(hookSource, /confirmBirthTimeEvidenceDraft/); }); -test("guided orchestration owns fallback copy, unique actions, polling, and retry", () => { +test("guided orchestration owns fallback copy, polling, and retry", () => { assert.match(automaticEffectsSource, /fallbackQuestionCopy/); assert.match(automaticEffectsSource, /requestBirthTimeGuidePrompt/); - assert.match(automaticEffectsSource, /crypto\.randomUUID\(\)/); assert.match(automaticEffectsSource, /runBirthTimeScoringPoll/); assert.match(hookSource, /retry_scoring/); assert.match(automaticEffectsSource, /AbortController/); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index 2e315005..2baa322c 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -24,7 +24,6 @@ test("draft revision publishes its new version before confirmation can fail", as precision: "month", date: "2008-09", }, { - createActionId: () => "45857b75-4718-4590-aaf5-7113a03ea765", revise: async () => revised, publish: (turn) => { published.push(turn); }, confirm: async () => { throw new TypeError("response unavailable"); }, diff --git a/frontend/tests/birth-time-stable-action-identity.test.ts b/frontend/tests/birth-time-stable-action-identity.test.ts new file mode 100644 index 00000000..cc48cbf1 --- /dev/null +++ b/frontend/tests/birth-time-stable-action-identity.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createStableActionIdentityRegistry, + runStableJourneyAction, + stableActionIdentity, +} from "../src/lib/birth-time-guided-effect-coordinator.ts"; +import { + answerDynamicBirthTimeChoice, + generateDynamicBirthTimeQuestion, +} from "../src/lib/birth-time-journey-client.ts"; +import { storedDynamicJourneyResponse } from "../src/lib/birth-time-journey-response.ts"; +import { dynamicCase, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts"; + +const firstId = "45857b75-4718-4590-aaf5-7113a03ea765"; +const secondId = "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea"; + +test("stable action identities survive failures and clear only after success", async () => { + const ids = [firstId, secondId]; + const registry = createStableActionIdentityRegistry( + () => ids.shift() ?? assert.fail("unexpected id allocation"), + ); + const seen: string[] = []; + const identity = stableActionIdentity({ + caseId: "case-a", turnVersion: 7, operation: "answer_dynamic_choice", + payload: ["question-a", "option-a"], + }); + const fail = () => registry.run(identity, async (actionId) => { + seen.push(actionId); + throw new TypeError("offline"); + }); + + await assert.rejects(fail()); + await assert.rejects(fail()); + await registry.run(identity, async (actionId) => { seen.push(actionId); }); + await registry.run(identity, async (actionId) => { seen.push(actionId); }); + + assert.deepEqual(seen, [firstId, firstId, firstId, secondId]); +}); + +test("action identity separates options, notes, and new turns", () => { + const base = { + caseId: "case-a", turnVersion: 7, operation: "answer_dynamic_choice", + payload: ["question-a", "option-a"], + } as const; + + assert.equal(stableActionIdentity(base), stableActionIdentity(base)); + assert.notEqual(stableActionIdentity(base), stableActionIdentity({ ...base, payload: ["question-a", "option-b"] })); + assert.notEqual(stableActionIdentity(base), stableActionIdentity({ ...base, turnVersion: 8 })); + assert.notEqual( + stableActionIdentity({ ...base, operation: "reframe_unmatched", payload: ["question-a", "较早"] }), + stableActionIdentity({ ...base, operation: "reframe_unmatched", payload: ["question-a", "较晚"] }), + ); +}); + +async function lostResponseRetry( + context: test.TestContext, + operation: "answer_dynamic_choice" | "generate_dynamic_question", + send: (actionId: string) => Promise, +) { + const bodies: string[] = []; + let attempts = 0; + context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => { + attempts += 1; + bodies.push(String(init?.body)); + if (attempts <= 2) throw new TypeError("response lost"); + return new Response(JSON.stringify(storedDynamicJourneyResponse(dynamicCase())), { status: 200 }); + }); + const registry = createStableActionIdentityRegistry(() => firstId); + const identity = { caseId: dynamicCase().id, turnVersion: 7, operation } as const; + + await assert.rejects(runStableJourneyAction(registry, identity, send)); + await runStableJourneyAction(registry, identity, send); + + assert.equal(attempts, 3); + assert.equal(new Set(bodies).size, 1); + assert.equal(JSON.parse(bodies[0]).actionId, firstId); +} + +test("two lost choice responses and a manual retry send one exact receipt", async (context) => { + await lostResponseRetry(context, "answer_dynamic_choice", (actionId) => answerDynamicBirthTimeChoice({ + caseId: dynamicCase().id, + actionId, + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: persistedQuestion.options[0].optionId, + })); +}); + +test("failed automatic generation reuses its action id on manual retry", async (context) => { + await lostResponseRetry(context, "generate_dynamic_question", (actionId) => ( + generateDynamicBirthTimeQuestion(dynamicCase().id, actionId, 7) + )); +});