From 8f98e8585d7bacfeb09773681464a3a4aa332f2f Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 14:58:57 +0800 Subject: [PATCH] fix: confirm dynamic birth-time candidates --- .../src/app/api/birth-time-journey/route.ts | 9 + .../hooks/use-birth-time-guided-journey.ts | 7 +- .../lib/birth-time-dynamic-action-receipt.ts | 7 + ...rth-time-dynamic-candidate-confirmation.ts | 76 ++++++++ frontend/src/lib/birth-time-journey-client.ts | 10 + .../birth-time-journey-dynamic-persistence.ts | 42 +++++ .../src/lib/birth-time-journey-request.ts | 6 + .../src/lib/birth-time-journey-service.ts | 13 ++ ...ete_and_dynamic_candidate_confirmation.sql | 125 +++++++++++++ .../tests/birth-time-agent-flow-e2e.test.ts | 52 ++++++ ...ime-dynamic-candidate-confirmation.test.ts | 171 ++++++++++++++++++ .../tests/birth-time-guided-client.test.ts | 3 + .../tests/birth-time-journey-memory-store.ts | 23 +++ 13 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/birth-time-dynamic-candidate-confirmation.ts create mode 100644 frontend/tests/birth-time-dynamic-candidate-confirmation.test.ts diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index da7c7426..e32ea0c5 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -184,6 +184,15 @@ export async function POST(request: Request) { resultId: parsed.data.resultId, time: parsed.data.time, }), "turn_advanced"); + case "confirm_dynamic_candidate": + return responseWithJourneyMetric(service.confirmDynamicCandidate({ + userId: user.id, + caseId: parsed.data.caseId, + actionId: parsed.data.actionId, + expectedVersion: parsed.data.turnVersion, + resultId: parsed.data.resultId, + time: parsed.data.time, + }), "turn_advanced"); default: { const exhaustive: never = parsed.data; return exhaustive; diff --git a/frontend/src/hooks/use-birth-time-guided-journey.ts b/frontend/src/hooks/use-birth-time-guided-journey.ts index 81a08092..5b4d5384 100644 --- a/frontend/src/hooks/use-birth-time-guided-journey.ts +++ b/frontend/src/hooks/use-birth-time-guided-journey.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { answerDynamicBirthTimeChoice, + confirmDynamicBirthTimeCandidate, confirmBirthTimeEvidenceDraft, draftBirthTimeEvidence, finishBirthTimeRectification, @@ -225,8 +226,10 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG (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 }), + turn, turn.journeyProtocol === "dynamic-choice-v2" ? "confirm_dynamic_candidate" : "confirm_guided_candidate", [resultId, time], + (actionId) => turn.journeyProtocol === "dynamic-choice-v2" + ? confirmDynamicBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }) + : confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }), )); const selectOption = (optionId: string) => operate((turn) => { if (turn.journeyProtocol !== "dynamic-choice-v2" diff --git a/frontend/src/lib/birth-time-dynamic-action-receipt.ts b/frontend/src/lib/birth-time-dynamic-action-receipt.ts index 55c9ca30..15e95587 100644 --- a/frontend/src/lib/birth-time-dynamic-action-receipt.ts +++ b/frontend/src/lib/birth-time-dynamic-action-receipt.ts @@ -20,6 +20,7 @@ export type DynamicActionReceipt = ReceiptBase & ( | { readonly kind: "pause" } | { readonly kind: "finish" } | { readonly kind: "resume" } + | { readonly kind: "confirm_candidate"; readonly resultId: string; readonly time: string } ); const receiptBase = { @@ -64,4 +65,10 @@ export const dynamicActionReceiptSchema: z.ZodType = z.uni z.object({ ...receiptBase, kind: z.literal("pause") }).strict(), z.object({ ...receiptBase, kind: z.literal("finish") }).strict(), z.object({ ...receiptBase, kind: z.literal("resume") }).strict(), + z.object({ + ...receiptBase, + kind: z.literal("confirm_candidate"), + resultId: z.string().uuid(), + time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), + }).strict(), ]).readonly(); diff --git a/frontend/src/lib/birth-time-dynamic-candidate-confirmation.ts b/frontend/src/lib/birth-time-dynamic-candidate-confirmation.ts new file mode 100644 index 00000000..f069be28 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-candidate-confirmation.ts @@ -0,0 +1,76 @@ +import { withConfirmedCandidate } from "./birth-time-evidence.ts"; +import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts"; +import { withDynamicAction } from "./birth-time-dynamic-transitions.ts"; +import { BirthTimeDynamicActionError } from "./birth-time-dynamic-actions.ts"; +import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts"; +import type { + BirthTimeJourneyPorts, + DynamicCandidateConfirmationCommand, + DynamicStoredRectificationCase, +} from "./birth-time-journey-service.ts"; +import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts"; + +function stale(stored: DynamicStoredRectificationCase, expectedVersion: number) { + return new StaleJourneyTurnError(stored.id, expectedVersion, stored.turnVersion); +} + +export function createDynamicCandidateConfirmation(ports: BirthTimeJourneyPorts) { + return { + async confirm(input: DynamicCandidateConfirmationCommand) { + const stored = await ports.store.loadCase(input.userId, input.caseId); + if (!stored) throw new BirthTimeDynamicActionError("case_not_found"); + if (stored.journeyProtocol !== "dynamic-choice-v2") { + throw new BirthTimeDynamicActionError("invalid_turn"); + } + const receipt = stored.dynamicControl.lastActionReceipt; + if (replayedDynamicAction(stored, input.actionId, input.expectedVersion, () => ( + stored.dynamicTurnState.nextAction.kind === "ready" + && stored.dynamicTurnState.nextAction.activeTime === input.time + && stored.snapshot.activeTime === input.time + && receipt?.kind === "confirm_candidate" + && receipt.actionId === input.actionId.toLowerCase() + && receipt.turnVersion === input.expectedVersion + && receipt.resultId === input.resultId + && receipt.time === input.time + ))) return storedDynamicJourneyResponse(stored); + + const action = stored.dynamicTurnState.nextAction; + const candidate = stored.candidateResult; + if ( + stored.turnVersion !== input.expectedVersion + || action.kind !== "request_candidate_confirmation" + || action.resultId !== input.resultId + || !stored.dynamicTurnState.permissions.canConfirmCandidate + || candidate?.resultId !== input.resultId + || candidate.canApply !== true + || candidate.winningSegment?.representativeTime !== input.time + ) throw stale(stored, input.expectedVersion); + + const transitioned = withDynamicAction( + stored, + { kind: "ready", activeTime: input.time }, + input.expectedVersion + 1, + ); + const updated = { + ...transitioned, + snapshot: withConfirmedCandidate(stored.snapshot, candidate, input.time), + currentChoiceQuestion: null, + dynamicTurnState: { + ...transitioned.dynamicTurnState, + permissions: { canConfirmCandidate: false }, + }, + dynamicControl: { + ...stored.dynamicControl, + lastActionReceipt: { + actionId: input.actionId.toLowerCase(), + kind: "confirm_candidate" as const, + turnVersion: input.expectedVersion, + resultId: input.resultId, + time: input.time, + }, + }, + } satisfies DynamicStoredRectificationCase; + return storedDynamicJourneyResponse(await ports.store.confirmDynamicCandidate(updated, input)); + }, + }; +} diff --git a/frontend/src/lib/birth-time-journey-client.ts b/frontend/src/lib/birth-time-journey-client.ts index 401da8e1..cb2645d1 100644 --- a/frontend/src/lib/birth-time-journey-client.ts +++ b/frontend/src/lib/birth-time-journey-client.ts @@ -161,6 +161,16 @@ export function confirmBirthTimeCandidate( return sendJourneyEvent({ type: "confirm_candidate", caseId, resultId, time }); } +export function confirmDynamicBirthTimeCandidate(input: { + readonly caseId: string; + readonly actionId: string; + readonly turnVersion: number; + readonly resultId: string; + readonly time: string; +}) { + return sendJourneyEvent({ type: "confirm_dynamic_candidate", ...input }); +} + export function confirmBirthTimeEvidenceDraft( caseId: string, actionId: string, diff --git a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts index e7e53f2e..9c6c16c2 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts @@ -8,6 +8,7 @@ import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol import type { DynamicScoringJobCommand, DynamicScoringJobFailureCommand, + DynamicCandidateConfirmationCommand, DynamicStoredRectificationCase, LegacyStoredRectificationCase, StoredRectificationCase, @@ -72,6 +73,8 @@ function privateState(value: DynamicStoredRectificationCase): DynamicPrivateJour function isStaleRpc(error: RpcError): boolean { return error.message.includes("stale_birth_time_dynamic_turn") || error.message.includes("stale_birth_time_dynamic_scoring_job") + || error.message.includes("stale_birth_time_dynamic_candidate") + || error.message.includes("birth_time_dynamic_candidate_invalid") || error.message.includes("stale_birth_time_legacy_upgrade"); } @@ -126,6 +129,45 @@ export function createDynamicTurnPersistence( } return { + async confirmDynamicCandidate( + value: DynamicStoredRectificationCase, + command: DynamicCandidateConfirmationCommand, + ): Promise { + const receipt = actionIdSchema.parse(command.actionId).toLowerCase(); + const result = await client.rpc("confirm_birth_time_dynamic_candidate", { + p_user_id: value.userId, + p_case_id: value.id, + p_result_id: command.resultId, + p_time: command.time, + p_action_id: receipt, + p_expected_version: command.expectedVersion, + p_snapshot: value.snapshot, + p_turn_state: publicTurn(value, command.expectedVersion + 1), + }); + const current = await loadCase(value.userId, value.id); + if (result.error) { + if (current?.journeyProtocol === "dynamic-choice-v2" + && current.processedActionIds.includes(receipt) + && samePersistedDynamicReceipt(value, current, receipt, command.expectedVersion)) { + return current; + } + if (isStaleRpc(result.error)) { + throw new StaleJourneyTurnError(value.id, command.expectedVersion, current?.turnVersion ?? 0); + } + throw new BirthTimeJourneyStoreError("update_case"); + } + const version = rpcVersionSchema.safeParse(result.data); + if (!current || current.journeyProtocol !== "dynamic-choice-v2") { + throw new BirthTimeJourneyStoreError("load_case"); + } + if (!version.success || version.data !== command.expectedVersion + 1 + || !current.processedActionIds.includes(receipt) + || !samePersistedDynamicReceipt(value, current, receipt, command.expectedVersion)) { + throw new StaleJourneyTurnError(value.id, command.expectedVersion, current.turnVersion); + } + return current; + }, + async saveDynamicTurn( value: DynamicStoredRectificationCase, expectedVersion: number, diff --git a/frontend/src/lib/birth-time-journey-request.ts b/frontend/src/lib/birth-time-journey-request.ts index ba4cb91b..fa375b85 100644 --- a/frontend/src/lib/birth-time-journey-request.ts +++ b/frontend/src/lib/birth-time-journey-request.ts @@ -72,6 +72,12 @@ export const birthTimeJourneyRequestSchema = z.discriminatedUnion("type", [ resultId: z.string().uuid(), time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), }).strict(), + z.object({ + type: z.literal("confirm_dynamic_candidate"), + ...mutationFields, + resultId: z.string().uuid(), + time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), + }).strict(), ]).superRefine((value, context) => { if (value.type === "revise_evidence_draft" && !lifeEventSchema.safeParse({ id: revisionValidationId, diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index 17acda54..626214f1 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -17,6 +17,7 @@ import type { TimeRange } from "./birth-time-dynamic-choice.ts"; import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journey-stored-protocol.ts"; import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts"; import { createDynamicJourneyMethods } from "./birth-time-dynamic-service-methods.ts"; +import { createDynamicCandidateConfirmation } from "./birth-time-dynamic-candidate-confirmation.ts"; export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError }; @@ -139,12 +140,22 @@ export type DynamicScoringJobCommand = { export type DynamicScoringJobFailureCommand = DynamicScoringJobCommand & { readonly failureCode: string }; +export type DynamicCandidateConfirmationCommand = { + readonly userId: string; + readonly caseId: string; + readonly actionId: string; + readonly expectedVersion: number; + readonly resultId: string; + readonly time: string; +}; + export interface BirthTimeJourneyStore { saveAssessment(value: PersistedJourneyAssessment): Promise; loadCase(userId: string, caseId: string): Promise; saveScoring(value: LegacyStoredRectificationCase): Promise; saveTurn(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string): Promise; saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise; + confirmDynamicCandidate(value: DynamicStoredRectificationCase, command: DynamicCandidateConfirmationCommand): Promise; completeDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobCommand): Promise; failDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobFailureCommand): Promise; createDynamicScoringJob?(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string, questionId: string, job: DynamicScoringJobSpec): Promise; @@ -193,6 +204,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) { const guidedCandidates = createGuidedCandidateActions(ports); const draftRevisions = createGuidedDraftRevisionActions(ports, turnActions.proposeEvidenceDraft); const dynamicMethods = createDynamicJourneyMethods(ports); + const dynamicCandidates = createDynamicCandidateConfirmation(ports); return { async assess(userId: string, assessment: BirthTimeAssessment): Promise { const scan = await scanAssessment(ports.engine, assessment); @@ -276,6 +288,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) { reviseEvidenceDraft: draftRevisions.revise, saveGuidedCandidate: guidedCandidates.save, confirmGuidedCandidate: guidedCandidates.confirm, + confirmDynamicCandidate: dynamicCandidates.confirm, pollScoringJob: scoringActions.pollScoringJob, ...dynamicMethods, }; diff --git a/frontend/supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql b/frontend/supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql index 3287e94f..0a4f41c5 100644 --- a/frontend/supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql +++ b/frontend/supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql @@ -5,3 +5,128 @@ create policy chat_sessions_delete_own using ((select auth.uid()) = user_id); grant delete on table public.chat_sessions to authenticated; commit; + +begin; + +create function public.confirm_birth_time_dynamic_candidate( + p_user_id uuid, + p_case_id uuid, + p_result_id uuid, + p_time time without time zone, + p_action_id uuid, + p_expected_version bigint, + p_snapshot jsonb, + p_turn_state jsonb +) +returns bigint +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.birth_time_rectification_cases%rowtype; + v_private public.birth_time_rectification_dynamic_state%rowtype; + v_new_version bigint; + v_time text := to_char(p_time, 'HH24:MI'); + v_receipt jsonb := jsonb_build_object( + 'actionId', p_action_id::text, + 'kind', 'confirm_candidate', + 'turnVersion', p_expected_version, + 'resultId', p_result_id::text, + 'time', to_char(p_time, 'HH24:MI') + ); +begin + select c.* into v_case + from public.birth_time_rectification_cases c + where c.id = p_case_id and c.user_id = p_user_id + for update; + if not found or v_case.journey_protocol is distinct from 'dynamic-choice-v2' then + raise exception 'birth_time_dynamic_case_not_found'; + end if; + + select s.* into v_private + from public.birth_time_rectification_dynamic_state s + where s.case_id = p_case_id and s.user_id = p_user_id + for update; + if not found then raise exception 'birth_time_dynamic_private_state_missing'; end if; + + if p_action_id = any(v_case.processed_action_ids) then + if v_case.turn_version is distinct from p_expected_version + 1 + or v_case.turn_state #>> '{nextAction,kind}' is distinct from 'ready' + or v_case.turn_state #>> '{nextAction,activeTime}' is distinct from v_time + or v_case.journey_snapshot ->> 'activeTime' is distinct from v_time + or v_private.dynamic_control -> 'lastActionReceipt' is distinct from v_receipt then + raise exception 'stale_birth_time_dynamic_candidate'; + end if; + return v_case.turn_version; + end if; + + if v_case.turn_version is distinct from p_expected_version + or v_case.status is distinct from 'confirming' + or v_case.journey_snapshot ->> 'state' is distinct from 'confirming' + or v_case.journey_snapshot ->> 'input' is distinct from 'candidate_confirmation' + or v_case.journey_snapshot ->> 'activeTime' is not null + or (v_case.journey_snapshot ->> 'canApply')::boolean is not true + or v_case.candidate_result_id is distinct from p_result_id + or v_case.candidate_result ->> 'confidence' is distinct from 'high' + or (v_case.candidate_result ->> 'canApply')::boolean is not true + or v_case.candidate_result #>> '{winningSegment,representativeTime}' is distinct from v_time + or v_case.turn_state #>> '{journeyProtocol}' is distinct from 'dynamic-choice-v2' + or v_case.turn_state #>> '{nextAction,kind}' is distinct from 'request_candidate_confirmation' + or v_case.turn_state #>> '{nextAction,resultId}' is distinct from p_result_id::text + or (v_case.turn_state #>> '{permissions,canConfirmCandidate}')::boolean is not true + or p_turn_state ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2' + or (p_turn_state ->> 'turnVersion')::bigint is distinct from p_expected_version + 1 + or p_turn_state #>> '{nextAction,kind}' is distinct from 'ready' + or p_turn_state #>> '{nextAction,activeTime}' is distinct from v_time + or p_turn_state #>> '{progress,phase}' is distinct from 'ready' + or (p_turn_state #>> '{permissions,canConfirmCandidate}')::boolean is not false + or p_snapshot ->> 'state' is distinct from 'ready' + or p_snapshot ->> 'route' is distinct from 'direct_chart' + or p_snapshot ->> 'activeTime' is distinct from v_time + or (p_snapshot ->> 'canApply')::boolean is not false then + raise exception 'birth_time_dynamic_candidate_invalid'; + end if; + + update public.birth_time_rectification_cases + set status = 'confirmed', + journey_snapshot = p_snapshot, + confirmed_time = p_time, + confirmed_at = now(), + turn_version = p_expected_version + 1, + turn_state = p_turn_state, + evidence_draft = null, + processed_action_ids = case when cardinality(processed_action_ids) >= 100 + then processed_action_ids[2:100] || p_action_id + else processed_action_ids || p_action_id end, + updated_at = now() + where id = p_case_id and user_id = p_user_id and turn_version = p_expected_version + returning turn_version into v_new_version; + if v_new_version is null then raise exception 'stale_birth_time_dynamic_candidate'; end if; + + update public.birth_time_rectification_dynamic_state + set dynamic_control = jsonb_set(dynamic_control, '{lastActionReceipt}', v_receipt, true), + updated_at = now() + where case_id = p_case_id and user_id = p_user_id; + if not found then raise exception 'birth_time_dynamic_private_state_missing'; end if; + + update public.profiles + set active_birth_time = p_time, + birth_time = p_time, + birth_time_status = 'confirmed', + rectification_case_id = p_case_id + where id = p_user_id; + if not found then raise exception 'birth_time_profile_not_found'; end if; + + return v_new_version; +end; +$$; + +revoke all on function public.confirm_birth_time_dynamic_candidate( + uuid, uuid, uuid, time without time zone, uuid, bigint, jsonb, jsonb +) from public, anon, authenticated; +grant execute on function public.confirm_birth_time_dynamic_candidate( + uuid, uuid, uuid, time without time zone, uuid, bigint, jsonb, jsonb +) to service_role; + +commit; diff --git a/frontend/tests/birth-time-agent-flow-e2e.test.ts b/frontend/tests/birth-time-agent-flow-e2e.test.ts index b923ac76..0e6ccc07 100644 --- a/frontend/tests/birth-time-agent-flow-e2e.test.ts +++ b/frontend/tests/birth-time-agent-flow-e2e.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createJourneyTelemetry } from "../src/lib/birth-time-journey-telemetry.ts"; +import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts"; import { actionIds, @@ -12,6 +13,57 @@ import { journeyCaseId, reachScoring, } from "./birth-time-agent-flow-test-support.ts"; +import { actionId as dynamicActionId, dynamicCase, ownerId } from "./birth-time-dynamic-persistence-fixture.ts"; +import { memoryStore } from "./birth-time-journey-memory-store.ts"; + +test("dynamic candidate confirmation remains ready after resume", async () => { + const result = candidate("high", "90000000-0000-4000-8000-000000000001"); + const current = dynamicCase(); + const memory = memoryStore({ + ...current, + candidateResult: result, + snapshot: { + ...current.snapshot, + state: "confirming", + assistantIntent: "confirm_candidate_time", + input: "candidate_confirmation", + confidence: "high", + canApply: true, + }, + currentChoiceQuestion: null, + dynamicTurnState: { + ...current.dynamicTurnState, + nextAction: { kind: "request_candidate_confirmation", resultId: result.resultId }, + progress: { ...current.dynamicTurnState.progress, phase: "result" }, + permissions: { canConfirmCandidate: true }, + }, + }); + const service = createBirthTimeJourneyService({ + store: memory.store, + engine: { + async scan() { throw new Error("unexpected scan"); }, + async score() { throw new Error("unexpected score"); }, + async scoreEvents() { throw new Error("unexpected event score"); }, + async buildDifferencePacket() { throw new Error("unexpected packet"); }, + async scoreChoices() { throw new Error("unexpected choice score"); }, + }, + }); + + const confirmation = await service.confirmDynamicCandidate({ + userId: ownerId, + caseId: current.id, + actionId: dynamicActionId, + expectedVersion: current.turnVersion, + resultId: result.resultId, + time: result.winningSegment?.representativeTime ?? "", + }); + const confirmationResume = await service.resume(ownerId, current.id); + + assert.equal(confirmation.nextAction.kind, "ready"); + assert.equal(confirmationResume.nextAction.kind, "ready"); + assert.equal(confirmation.snapshot.activeTime, "14:24"); + assert.equal(confirmationResume.snapshot.activeTime, "14:24"); +}); test("fake Agent and engine complete baseline, adaptive, low, and no-apply flow", async () => { const harness = createHarness({ initial: guidedCase(), result: candidate("low", "10000000-0000-4000-8000-000000000001") }); diff --git a/frontend/tests/birth-time-dynamic-candidate-confirmation.test.ts b/frontend/tests/birth-time-dynamic-candidate-confirmation.test.ts new file mode 100644 index 00000000..608055ab --- /dev/null +++ b/frontend/tests/birth-time-dynamic-candidate-confirmation.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { candidateResultSchema } from "../src/lib/birth-time-evidence.ts"; +import { birthTimeJourneyRequestSchema } from "../src/lib/birth-time-journey-request.ts"; +import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; +import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts"; +import { actionId, dynamicCase, ownerId } from "./birth-time-dynamic-persistence-fixture.ts"; +import { memoryStore } from "./birth-time-journey-memory-store.ts"; + +const highCandidate = candidateResultSchema.parse({ + resultId: "f8eb3bc5-80eb-40fc-b937-e62ea37c3236", + confidence: "high", + canApply: true, + winningSegment: { + startTime: "17:13", + endTime: "17:17", + representativeTime: "17:15", + widthMinutes: 5, + }, + eventCount: 4, + domainCount: 3, + topScore: 18, + secondScore: 8, + marginPercent: 55, + reasons: ["One segment has consistent evidence."], + evidence: [], + algorithmVersion: "birth-time-event-scoring-v1", +}); + +const migration = readFileSync(new URL( + "../supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql", + import.meta.url, +), "utf8"); + +function highConfidenceDynamicCase() { + const current = dynamicCase(); + return { + ...current, + candidateResult: highCandidate, + snapshot: { + ...current.snapshot, + state: "confirming" as const, + assistantIntent: "confirm_candidate_time" as const, + input: "candidate_confirmation" as const, + confidence: "high" as const, + canApply: true, + }, + currentChoiceQuestion: null, + dynamicTurnState: { + ...current.dynamicTurnState, + nextAction: { + kind: "request_candidate_confirmation" as const, + resultId: highCandidate.resultId, + }, + progress: { ...current.dynamicTurnState.progress, phase: "result" as const }, + permissions: { canConfirmCandidate: true }, + }, + }; +} + +test("dynamic confirmation atomically reaches ready", async () => { + const current = highConfidenceDynamicCase(); + const memory = memoryStore(current); + const service = createBirthTimeJourneyService({ + store: memory.store, + engine: { + async scan() { throw new Error("unexpected scan"); }, + async score() { throw new Error("unexpected score"); }, + async scoreEvents() { throw new Error("unexpected event score"); }, + async buildDifferencePacket() { throw new Error("unexpected packet"); }, + async scoreChoices() { throw new Error("unexpected choice score"); }, + }, + }); + + const result = await service.confirmDynamicCandidate({ + userId: ownerId, + caseId: current.id, + actionId, + expectedVersion: current.dynamicTurnState.turnVersion, + resultId: current.candidateResult.resultId, + time: current.candidateResult.winningSegment?.representativeTime ?? "", + }); + + assert.equal(result.nextAction.kind, "ready"); + assert.equal(result.snapshot.activeTime, "17:15"); +}); + +test("dynamic confirmation replays only its exact receipt and rejects stale versions", async () => { + const current = highConfidenceDynamicCase(); + const memory = memoryStore(current); + const service = createBirthTimeJourneyService({ + store: memory.store, + engine: { + async scan() { throw new Error("unexpected scan"); }, + async score() { throw new Error("unexpected score"); }, + async scoreEvents() { throw new Error("unexpected event score"); }, + async buildDifferencePacket() { throw new Error("unexpected packet"); }, + async scoreChoices() { throw new Error("unexpected choice score"); }, + }, + }); + const command = { + userId: ownerId, + caseId: current.id, + actionId, + expectedVersion: current.turnVersion, + resultId: highCandidate.resultId, + time: "17:15", + }; + + const first = await service.confirmDynamicCandidate(command); + const replay = await service.confirmDynamicCandidate(command); + + assert.deepEqual(replay, first); + assert.equal(memory.committedTurnWrites(), 1); + await assert.rejects(service.confirmDynamicCandidate({ + ...command, + actionId: "f3a64be6-65d3-498c-a86d-847cf104e594", + }), StaleJourneyTurnError); +}); + +test("dynamic confirmation rejects a non-representative minute before writing", async () => { + const current = highConfidenceDynamicCase(); + const memory = memoryStore(current); + const service = createBirthTimeJourneyService({ + store: memory.store, + engine: { + async scan() { throw new Error("unexpected scan"); }, + async score() { throw new Error("unexpected score"); }, + async scoreEvents() { throw new Error("unexpected event score"); }, + async buildDifferencePacket() { throw new Error("unexpected packet"); }, + async scoreChoices() { throw new Error("unexpected choice score"); }, + }, + }); + + await assert.rejects(service.confirmDynamicCandidate({ + userId: ownerId, + caseId: current.id, + actionId, + expectedVersion: current.turnVersion, + resultId: highCandidate.resultId, + time: "17:14", + }), StaleJourneyTurnError); + assert.equal(memory.committedTurnWrites(), 0); +}); + +test("dynamic confirmation request accepts only its strict receipt, version, result, and time", () => { + const valid = { + type: "confirm_dynamic_candidate", + caseId: dynamicCase().id, + actionId, + turnVersion: 7, + resultId: highCandidate.resultId, + time: "17:15", + } as const; + + assert.equal(birthTimeJourneyRequestSchema.safeParse(valid).success, true); + assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, time: "17:15:00" }).success, false); + assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, turnVersion: -1 }).success, false); + assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, candidateResult: highCandidate }).success, false); +}); + +test("dynamic confirmation RPC locks the v2 case and is service-role only", () => { + assert.match(migration, /create function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?returns bigint/i); + assert.match(migration, /for update[\s\S]*journey_protocol is distinct from 'dynamic-choice-v2'/i); + assert.match(migration, /p_action_id = any\(v_case\.processed_action_ids\)/i); + assert.match(migration, /candidate_result_id is distinct from p_result_id[\s\S]*representativeTime/i); + assert.match(migration, /turn_version = p_expected_version \+ 1[\s\S]*turn_state = p_turn_state/i); + assert.match(migration, /set active_birth_time = p_time,[\s\S]*birth_time_status = 'confirmed',[\s\S]*rectification_case_id = p_case_id/i); + assert.match(migration, /revoke all on function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?from public, anon, authenticated;[\s\S]*?grant execute on function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?to service_role;/i); +}); diff --git a/frontend/tests/birth-time-guided-client.test.ts b/frontend/tests/birth-time-guided-client.test.ts index 9db5c797..e62aade0 100644 --- a/frontend/tests/birth-time-guided-client.test.ts +++ b/frontend/tests/birth-time-guided-client.test.ts @@ -5,6 +5,7 @@ import { reviseBirthTimeEvidenceDraft, saveGuidedBirthTimeCandidate, } from "../src/lib/birth-time-guided-client.ts"; +import { confirmDynamicBirthTimeCandidate } from "../src/lib/birth-time-journey-client.ts"; import { birthTimeJourneyRequestSchema } from "../src/lib/birth-time-journey-request.ts"; import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts"; @@ -60,11 +61,13 @@ test("guided client emits only deterministic mutation fields", async (context) = await reviseBirthTimeEvidenceDraft({ caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, precision: "month", date: "2019-07" }); await saveGuidedBirthTimeCandidate({ caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId }); await confirmGuidedBirthTimeCandidate({ caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId, time: "14:24" }); + await confirmDynamicBirthTimeCandidate({ caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId, time: "14:24" }); assert.deepEqual(payloads, [ { type: "revise_evidence_draft", caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, precision: "month", date: "2019-07" }, { type: "save_guided_candidate", caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId }, { type: "confirm_guided_candidate", caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId, time: "14:24" }, + { type: "confirm_dynamic_candidate", caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId, time: "14:24" }, ]); }); diff --git a/frontend/tests/birth-time-journey-memory-store.ts b/frontend/tests/birth-time-journey-memory-store.ts index 0efe7440..fef88894 100644 --- a/frontend/tests/birth-time-journey-memory-store.ts +++ b/frontend/tests/birth-time-journey-memory-store.ts @@ -150,6 +150,29 @@ export function memoryStore( committedTurnWrites += 1; return savedDynamic; }, + async confirmDynamicCandidate(value, command) { + if (!savedCase || !savedDynamicCase) throw new MissingTestCaseError(); + const receipt = command.actionId.toLowerCase(); + if ((savedCase.processedActionIds ?? []).includes(receipt)) { + if (samePersistedDynamicReceipt(value, savedDynamicCase, receipt, command.expectedVersion)) { + return savedDynamicCase; + } + throw new StaleJourneyTurnError(value.id, command.expectedVersion, savedDynamicCase.turnVersion); + } + if (savedCase.journeyProtocol !== "dynamic-choice-v2" || savedCase.turnVersion !== command.expectedVersion) { + throw new StaleJourneyTurnError(savedCase.id, command.expectedVersion, savedCase.turnVersion ?? 0); + } + const saved = { + ...value, + turnVersion: command.expectedVersion + 1, + dynamicTurnState: { ...value.dynamicTurnState, turnVersion: command.expectedVersion + 1 }, + processedActionIds: [...(savedCase.processedActionIds ?? []), receipt], + }; + savedCase = saved; + savedDynamicCase = saved; + committedTurnWrites += 1; + return saved; + }, async completeDynamicScoringJob(value, command) { return persistDynamicScoring(value, { kind: "complete", command, result: value.candidateResult }); },