fix: preserve dynamic action receipts

This commit is contained in:
Jesse_Chen
2026-07-19 10:12:18 +08:00
parent db5a6fbe52
commit db5c12f67f
8 changed files with 217 additions and 52 deletions
@@ -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<Awaited<ReturnType<typeof requestBirthTimeGuidePrompt>>>();
const generationRequests = createIdentityRequestCache<Awaited<ReturnType<typeof generateDynamicBirthTimeQuestion>>>();
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<Awaited<ReturnType<typeof requestBirthTimeGuidePrompt>>>());
const [generationRequests] = useState(() => createIdentityRequestCache<Awaited<ReturnType<typeof generateDynamicBirthTimeQuestion>>>());
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}`
@@ -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 = <T>(
caseId: string,
turnVersion: number,
operation: string,
payload: readonly string[],
send: (actionId: string) => Promise<T>,
) => runStableJourneyAction(actionRegistry, {
caseId,
turnVersion,
operation,
payload,
}, send);
const stableAction = <T>(
turn: JourneyClientResponse,
operation: string,
payload: readonly string[],
send: (actionId: string) => Promise<T>,
) => 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"
@@ -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<JourneyClientResponse>;
readonly publish: (turn: JourneyClientResponse) => void;
readonly confirm: (command: ConfirmationCommand) => Promise<JourneyClientResponse>;
@@ -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,
});
@@ -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<T>() {
const requests = new Map<string, Promise<T>>();
return {
@@ -16,6 +32,31 @@ export function createIdentityRequestCache<T>() {
};
}
export function createStableActionIdentityRegistry(
createId: () => string = () => globalThis.crypto.randomUUID(),
) {
const actionIds = new Map<string, string>();
return {
async run<T>(identity: string, operation: (actionId: string) => Promise<T>): Promise<T> {
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<typeof createStableActionIdentityRegistry>;
export function runStableJourneyAction<T>(
registry: StableActionIdentityRegistry,
identity: StableActionIdentityInput,
operation: (actionId: string) => Promise<T>,
): Promise<T> {
return registry.run(stableActionIdentity(identity), operation);
}
export function scheduleCancellableStart(start: () => void): () => void {
const timer = globalThis.setTimeout(start, 0);
return () => globalThis.clearTimeout(timer);
@@ -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/);
});
+1 -2
View File
@@ -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/);
@@ -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"); },
@@ -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<unknown>,
) {
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)
));
});