feat: expose dynamic rectification actions

This commit is contained in:
Jesse_Chen
2026-07-19 09:45:32 +08:00
parent 5c5b60b3e4
commit 806d2babbf
15 changed files with 579 additions and 101 deletions
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { birthTimeGuideRequestSchema } from "../src/lib/birth-time-guide-agent.ts";
import { birthTimeJourneyRequestSchema } from "../src/lib/birth-time-journey-request.ts";
import { parseJourneyResponse } 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 caseId = "45857b75-4718-4590-aaf5-7113a03ea765";
const actionId = "a9890e09-d535-46f0-9a36-86017515a5a1";
test("choice commands accept only public ids", () => {
const valid = {
type: "answer_dynamic_choice",
caseId,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
};
assert.equal(birthTimeJourneyRequestSchema.safeParse(valid).success, true);
for (const field of ["partitionId", "candidateScores", "confidence", "time"] as const) {
assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, [field]: "forged" }).success, false);
}
});
test("unmatched context is optional, trimmed, and bounded", () => {
const valid = {
type: "reframe_unmatched",
caseId,
actionId,
turnVersion: 8,
questionId: persistedQuestion.questionId,
note: " 更像是 2017 年 ",
};
const parsed = birthTimeGuideRequestSchema.parse(valid);
assert.equal(parsed.type === "reframe_unmatched" ? parsed.note : null, "更像是 2017 年");
assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, note: "字".repeat(241) }).success, false);
assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, partitionId: "private" }).success, false);
});
test("dynamic responses preserve the protocol discriminant without private scoring data", () => {
const response = storedDynamicJourneyResponse(dynamicCase());
const parsed = parseJourneyResponse(response);
assert.equal(parsed.journeyProtocol, "dynamic-choice-v2");
assert.equal(parsed.nextAction.kind, "ask_dynamic_choice");
const serialized = JSON.stringify(parsed);
assert.doesNotMatch(serialized, /partitionId|candidateScores|agentContext|candidateModel/);
assert.throws(() => parseJourneyResponse({ ...response, partitionId: "forged" }));
});
test("dynamic routes authenticate before parsing and dispatch scoped methods", () => {
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/);
});
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
draftBirthTimeEvidence,
generateDynamicBirthTimeQuestion,
reframeUnmatchedBirthTimeAnswer,
requestBirthTimeGuidePrompt,
} from "../src/lib/birth-time-journey-client.ts";
import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts";
@@ -94,3 +96,35 @@ test("guide client rejects raw model metadata and malformed nested turns", async
await assert.rejects(requestBirthTimeGuidePrompt(caseId));
});
test("dynamic guide commands send only public coordination fields", async (context) => {
const payloads: unknown[] = [];
context.mock.method(globalThis, "fetch", async (
_input: string | URL | Request,
init?: RequestInit,
) => {
payloads.push(JSON.parse(String(init?.body)));
return new Response(JSON.stringify(highConfirmationTurn), { status: 200 });
});
await generateDynamicBirthTimeQuestion(caseId, actionId, 4);
await reframeUnmatchedBirthTimeAnswer({
caseId,
actionId,
turnVersion: 5,
questionId: "11111111-1111-4111-8111-111111111111",
note: " 时间更早 ",
});
assert.deepEqual(payloads, [
{ type: "generate_dynamic_question", caseId, actionId, turnVersion: 4 },
{
type: "reframe_unmatched",
caseId,
actionId,
turnVersion: 5,
questionId: "11111111-1111-4111-8111-111111111111",
note: "时间更早",
},
]);
});
+6 -5
View File
@@ -9,6 +9,7 @@ const turnSource = read("../src/components/birth-time-guide-turn.tsx");
const draftSource = read("../src/components/birth-time-evidence-draft-card.tsx");
const candidateSource = read("../src/components/birth-time-candidate-result.tsx");
const hookSource = read("../src/hooks/use-birth-time-guided-journey.ts");
const automaticEffectsSource = read("../src/hooks/use-birth-time-automatic-journey-effects.ts");
test("guided rectification renders exactly the persisted action, never a questionnaire slice", () => {
assert.match(rectificationSource, /journey\.nextAction/);
@@ -36,12 +37,12 @@ test("draft review is explicit, domain locked, and incomplete confirmation stays
});
test("guided orchestration owns fallback copy, unique actions, polling, and retry", () => {
assert.match(hookSource, /fallbackQuestionCopy/);
assert.match(hookSource, /requestBirthTimeGuidePrompt/);
assert.match(hookSource, /crypto\.randomUUID\(\)/);
assert.match(hookSource, /runBirthTimeScoringPoll/);
assert.match(automaticEffectsSource, /fallbackQuestionCopy/);
assert.match(automaticEffectsSource, /requestBirthTimeGuidePrompt/);
assert.match(automaticEffectsSource, /crypto\.randomUUID\(\)/);
assert.match(automaticEffectsSource, /runBirthTimeScoringPoll/);
assert.match(hookSource, /retry_scoring/);
assert.match(hookSource, /AbortController/);
assert.match(automaticEffectsSource, /AbortController/);
});
test("candidate UI is nextAction-gated and keeps application boundary explicit", () => {
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { runBirthTimeScoringPoll } from "../src/lib/birth-time-guided-polling.ts";
import { claimMutation, publishCurrentJourney } from "../src/lib/birth-time-guided-effect-coordinator.ts";
import { storedDynamicJourneyResponse } from "../src/lib/birth-time-journey-response.ts";
import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts";
import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts";
import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts";
@@ -94,3 +97,43 @@ test("bounded polling preserves the pending turn instead of inventing completion
assert.equal(result.kind, "exhausted");
assert.equal(result.turn.nextAction.kind, "score_pending");
});
test("duplicate option clicks publish one advanced turn", async () => {
const gate = Promise.withResolvers<typeof completedTurn>();
const sent: string[] = [];
const published: typeof completedTurn[] = [];
const lock = { current: false };
const select = async (optionId: string) => {
const release = claimMutation(lock);
if (release === null) return;
sent.push(optionId);
const turn = await gate.promise;
published.push(turn);
release();
};
const first = select("primary-option");
const duplicate = select("primary-option");
gate.resolve(completedTurn);
await Promise.all([first, duplicate]);
assert.deepEqual(sent, ["primary-option"]);
assert.deepEqual(published, [completedTurn]);
});
test("a stale generated question cannot replace a newer turn", () => {
const expected = parseJourneyResponse(storedDynamicJourneyResponse(dynamicCase()));
const current = parseJourneyResponse({ ...expected, turnVersion: expected.turnVersion + 1 });
let published = 0;
const accepted = publishCurrentJourney({
expected,
current,
next: expected,
publish: () => { published += 1; },
});
assert.equal(accepted, false);
assert.equal(current.turnVersion, 8);
assert.equal(published, 0);
});
@@ -72,6 +72,21 @@ test("request identity cache and scheduled polling deduplicate Strict Mode start
assert.equal(starts, 1);
});
test("a failed generation identity remains retryable", async () => {
const cache = createIdentityRequestCache<number>();
let attempts = 0;
await assert.rejects(cache.run("case:4", async () => {
attempts += 1;
throw new TypeError("offline");
}));
assert.equal(await cache.run("case:4", async () => {
attempts += 1;
return 8;
}), 8);
assert.equal(attempts, 2);
});
test("a resolved mutation cannot publish over a changed case or version", () => {
const expected = guidedBirthTimePreview("birth-time-rectification");
const current = parseJourneyResponse({ ...expected, turnVersion: expected.turnVersion + 1 });