|
|
|
@@ -8,11 +8,23 @@ import {
|
|
|
|
|
publicDecisionFields,
|
|
|
|
|
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
|
|
|
|
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
|
|
|
|
|
import { inspectDiscriminatorProbes, selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
|
|
|
|
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
|
|
|
|
import {
|
|
|
|
|
inspectDiscriminatorProbes,
|
|
|
|
|
selectDiscriminatorProbe,
|
|
|
|
|
type CandidateDiscriminatorProbe,
|
|
|
|
|
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
|
|
|
|
import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
|
|
|
|
import {
|
|
|
|
|
applyChoiceWithoutEvidence,
|
|
|
|
|
nakshatraBoundaryProbe,
|
|
|
|
|
stampChoiceSchemaWithProbe,
|
|
|
|
|
withNakshatraBoundaryProbe,
|
|
|
|
|
} from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
|
|
|
|
|
import { buildMethodFollowupPlan, conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
|
|
|
|
import { awaitTurnExitBeforeResponse, finalizeSuccessfulTurnExit } from "../src/lib/rectification-agentic/v9/turn-exit.ts";
|
|
|
|
|
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
|
|
|
|
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
|
|
|
|
import { projectCurrentQuestion, projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
|
|
|
|
import {
|
|
|
|
|
evidenceLedgerFingerprint,
|
|
|
|
|
parseV9CaseDossier,
|
|
|
|
@@ -20,9 +32,14 @@ import {
|
|
|
|
|
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
|
|
|
|
import { safeCaseProjection } from "../src/mastra/rectification-v9-tools.ts";
|
|
|
|
|
import {
|
|
|
|
|
CASE_ID,
|
|
|
|
|
FOCUS_ID,
|
|
|
|
|
TURN_ID,
|
|
|
|
|
USER_ID,
|
|
|
|
|
candidateSnapshotFixture,
|
|
|
|
|
computeFixture,
|
|
|
|
|
dossierFixture,
|
|
|
|
|
fakeAccounting,
|
|
|
|
|
} from "./rectification-v9-test-support.ts";
|
|
|
|
|
|
|
|
|
|
const SEPARATED = [
|
|
|
|
@@ -210,6 +227,391 @@ function readSource(relative: string) {
|
|
|
|
|
return readFileSync(new URL(relative, import.meta.url), "utf8");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ADVANCING_RECTIFICATION_ACTIONS = [
|
|
|
|
|
"opening",
|
|
|
|
|
"message",
|
|
|
|
|
"answer_choice",
|
|
|
|
|
"stop_and_review",
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
function routeExitContract(route: string, turnExit: string) {
|
|
|
|
|
const immediateStart = route.indexOf("if (immediateResponse)");
|
|
|
|
|
const immediateEnd = route.indexOf("const requestTime", immediateStart);
|
|
|
|
|
const immediateExit = immediateStart >= 0 && immediateEnd > immediateStart
|
|
|
|
|
? route.slice(immediateStart, immediateEnd)
|
|
|
|
|
: "";
|
|
|
|
|
const awaitIndex = immediateExit.indexOf("await awaitTurnExitBeforeResponse");
|
|
|
|
|
const returnIndex = immediateExit.indexOf("return response", awaitIndex);
|
|
|
|
|
const immediateAwaited = awaitIndex >= 0
|
|
|
|
|
&& returnIndex > awaitIndex
|
|
|
|
|
&& /finalizeSuccessfulTurnExit\s*\(/.test(immediateExit.slice(awaitIndex, returnIndex));
|
|
|
|
|
const readOnlyExplicitlyExcluded = /input\.action\s*===\s*["']read_only["']/.test(turnExit);
|
|
|
|
|
|
|
|
|
|
const resultStart = route.indexOf("if (!result.ok)");
|
|
|
|
|
const doneIndex = route.indexOf('send({ type: "done"', resultStart);
|
|
|
|
|
const streamedSuccess = resultStart >= 0 && doneIndex > resultStart
|
|
|
|
|
? route.slice(resultStart, doneIndex)
|
|
|
|
|
: "";
|
|
|
|
|
const streamingAwaited = /await\s+finalizeSuccessfulTurnExit\s*\(/.test(streamedSuccess);
|
|
|
|
|
const executionBody = /RECTIFICATION_ACTION_EXECUTION\s*=\s*\{([\s\S]*?)\}\s*as const/.exec(turnExit)?.[1] ?? "";
|
|
|
|
|
const executionActions = [...executionBody.matchAll(/^\s*([a-z_]+):/gm)].map((match) => match[1]);
|
|
|
|
|
|
|
|
|
|
return { executionActions, immediateAwaited, readOnlyExplicitlyExcluded, streamingAwaited };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test("nonterminal invariant 1: every advancing action exits through an awaited common gate", () => {
|
|
|
|
|
const route = readSource("../src/app/api/rectification/agent/route.ts");
|
|
|
|
|
const turnExit = readSource("../src/lib/rectification-agentic/v9/turn-exit.ts");
|
|
|
|
|
const declared = /action:\s*z\.enum\(\[([^\]]+)\]\)/.exec(route)?.[1]
|
|
|
|
|
?.match(/["']([^"']+)["']/g)
|
|
|
|
|
?.map((value) => value.slice(1, -1)) ?? [];
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
declared.filter((action) => action !== "read_only"),
|
|
|
|
|
[...ADVANCING_RECTIFICATION_ACTIONS],
|
|
|
|
|
"new advancing actions must enter this invariant instead of silently bypassing the exit gate",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const contract = routeExitContract(route, turnExit);
|
|
|
|
|
const failures: string[] = [];
|
|
|
|
|
if (contract.executionActions.join(",") !== declared.join(",")) {
|
|
|
|
|
failures.push("schema actions and execution declarations differ");
|
|
|
|
|
}
|
|
|
|
|
for (const action of ADVANCING_RECTIFICATION_ACTIONS) {
|
|
|
|
|
const immediate = action === "message" || action === "answer_choice" || action === "stop_and_review";
|
|
|
|
|
const streamed = action === "opening" || action === "message";
|
|
|
|
|
if (immediate && !contract.immediateAwaited) {
|
|
|
|
|
failures.push(`${action}: HTTP 200 response can return without awaiting the common exit gate`);
|
|
|
|
|
}
|
|
|
|
|
if (streamed && !contract.streamingAwaited) {
|
|
|
|
|
failures.push(`${action}: stream can emit done before awaiting the common exit gate`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!contract.readOnlyExplicitlyExcluded) {
|
|
|
|
|
failures.push("read_only: common gate exclusion is not explicit at the shared exit");
|
|
|
|
|
}
|
|
|
|
|
assert.deepEqual(failures, []);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("nonterminal invariant 2: exhausted probes take the next available server-owned exit", () => {
|
|
|
|
|
const nakshatraProbe: CandidateDiscriminatorProbe = {
|
|
|
|
|
probeId: "probe:nakshatra-boundary:incident",
|
|
|
|
|
candidateSetVersion: "incident",
|
|
|
|
|
question: "哪一组日常节奏更像你?",
|
|
|
|
|
informationGain: 0.01,
|
|
|
|
|
semanticKey: "nakshatra-boundary:incident",
|
|
|
|
|
candidateSplitHash: "nakshatra-boundary:incident:early|late",
|
|
|
|
|
expectedOutcomes: [
|
|
|
|
|
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07"] },
|
|
|
|
|
{ outcomeId: "weak_yes", supportsCandidateIds: ["05:07"], conflictsCandidateIds: ["05:00"] },
|
|
|
|
|
{ outcomeId: "no", supportsCandidateIds: [], conflictsCandidateIds: [] },
|
|
|
|
|
{ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] },
|
|
|
|
|
],
|
|
|
|
|
sourceFeatures: [],
|
|
|
|
|
domain: "appearance",
|
|
|
|
|
year: null,
|
|
|
|
|
choiceKind: "varga_style",
|
|
|
|
|
styleOptions: [
|
|
|
|
|
{ label: "A 组:直接、外放", answerClass: "yes" },
|
|
|
|
|
{ label: "B 组:克制、内敛", answerClass: "weak_yes" },
|
|
|
|
|
{ label: "两组都不太像", answerClass: "no" },
|
|
|
|
|
{ label: "一时说不好", answerClass: "unsure" },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
const cases = [
|
|
|
|
|
{
|
|
|
|
|
name: "holdout",
|
|
|
|
|
holdoutValidation: "not_started" as const,
|
|
|
|
|
datedMethodCollectOpen: false,
|
|
|
|
|
nakshatraBoundaryProbe: null,
|
|
|
|
|
nextAction: "ask_holdout_validation",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "dated collect",
|
|
|
|
|
holdoutValidation: "unavailable" as const,
|
|
|
|
|
datedMethodCollectOpen: true,
|
|
|
|
|
nakshatraBoundaryProbe: null,
|
|
|
|
|
nextAction: "ask_fact_collection",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "nakshatra boundary",
|
|
|
|
|
holdoutValidation: "unavailable" as const,
|
|
|
|
|
datedMethodCollectOpen: false,
|
|
|
|
|
nakshatraBoundaryProbe: nakshatraProbe,
|
|
|
|
|
nextAction: "ask_candidate_discriminator",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "explicit range exit",
|
|
|
|
|
holdoutValidation: "unavailable" as const,
|
|
|
|
|
datedMethodCollectOpen: false,
|
|
|
|
|
nakshatraBoundaryProbe: null,
|
|
|
|
|
nextAction: "offer_provisional_range",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const fixture of cases) {
|
|
|
|
|
const decision = decideRectification({
|
|
|
|
|
methodCoverageAll: true,
|
|
|
|
|
trainingGateOpen: true,
|
|
|
|
|
snapshotCurrent: true,
|
|
|
|
|
candidateScores: [
|
|
|
|
|
{ time: "05:00", score: 34 },
|
|
|
|
|
{ time: "05:06", score: 33 },
|
|
|
|
|
{ time: "05:07", score: 33 },
|
|
|
|
|
],
|
|
|
|
|
discriminatorProbe: null,
|
|
|
|
|
holdoutValidation: fixture.holdoutValidation,
|
|
|
|
|
datedMethodCollectOpen: fixture.datedMethodCollectOpen,
|
|
|
|
|
nakshatraBoundaryProbe: fixture.nakshatraBoundaryProbe,
|
|
|
|
|
userStopped: false,
|
|
|
|
|
datedEventCount: 7,
|
|
|
|
|
datedDomainCount: 3,
|
|
|
|
|
engineCeiling: ENGINE_OPEN,
|
|
|
|
|
});
|
|
|
|
|
assert.equal(decision.separation.sufficient, false, fixture.name);
|
|
|
|
|
assert.equal(decision.canAdopt, false, fixture.name);
|
|
|
|
|
assert.equal(decision.nextAction, fixture.nextAction, fixture.name);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("nakshatra boundary is a consumable four-answer probe and is not asked twice", () => {
|
|
|
|
|
const base = buildInferenceState({
|
|
|
|
|
range_start: "04:51",
|
|
|
|
|
range_end: "05:15",
|
|
|
|
|
candidates: [
|
|
|
|
|
{ id: "04:51", time: "04:51", relative_support: 34 },
|
|
|
|
|
{ id: "05:03", time: "05:03", relative_support: 33 },
|
|
|
|
|
{ id: "05:15", time: "05:15", relative_support: 32 },
|
|
|
|
|
],
|
|
|
|
|
events: Array.from({ length: 7 }, (_, index) => ({
|
|
|
|
|
id: `incident-event-${index + 1}`,
|
|
|
|
|
domain: ["career", "relationship", "education"][index % 3]!,
|
|
|
|
|
year: 2016 + index,
|
|
|
|
|
precision: "year" as const,
|
|
|
|
|
})),
|
|
|
|
|
probes: [],
|
|
|
|
|
});
|
|
|
|
|
const boundary = {
|
|
|
|
|
near_boundary: true,
|
|
|
|
|
user_meaning: "平时做决定时,哪一组节奏更像你?",
|
|
|
|
|
options: [
|
|
|
|
|
{ key: "A" as const, time_bias: "earlier" as const, traits: ["直接", "行动快"] },
|
|
|
|
|
{ key: "B" as const, time_bias: "later" as const, traits: ["克制", "先观察"] },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
const probe = nakshatraBoundaryProbe(base, boundary);
|
|
|
|
|
assert.ok(probe);
|
|
|
|
|
assert.deepEqual(probe.style_options?.map((item) => item.answer_class), ["yes", "weak_yes", "no", "unsure"]);
|
|
|
|
|
assert.match(probe.style_options?.[0]?.label ?? "", /直接.*行动快/);
|
|
|
|
|
assert.match(probe.style_options?.[1]?.label ?? "", /克制.*先观察/);
|
|
|
|
|
|
|
|
|
|
const withProbe = withNakshatraBoundaryProbe(base, boundary);
|
|
|
|
|
assert.ok(withProbe);
|
|
|
|
|
const schema = stampChoiceSchemaWithProbe({
|
|
|
|
|
choice: {
|
|
|
|
|
options: [
|
|
|
|
|
{ key: "A", answer_class: "yes" },
|
|
|
|
|
{ key: "B", answer_class: "weak_yes" },
|
|
|
|
|
{ key: "C", answer_class: "no" },
|
|
|
|
|
{ key: "D", answer_class: "unsure" },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
}, withProbe, "nakshatra-boundary:incident", {
|
|
|
|
|
probe_id: probe.id,
|
|
|
|
|
semantic_key: probe.semantic_key,
|
|
|
|
|
candidate_split_hash: probe.candidate_split_hash,
|
|
|
|
|
});
|
|
|
|
|
const applied = applyChoiceWithoutEvidence(withProbe, {
|
|
|
|
|
choiceKey: "A",
|
|
|
|
|
schema,
|
|
|
|
|
questionId: "nakshatra-boundary:incident",
|
|
|
|
|
domain: "appearance",
|
|
|
|
|
classifiedFrom: "choice",
|
|
|
|
|
});
|
|
|
|
|
assert.equal(applied.applied, true);
|
|
|
|
|
assert.equal(applied.reason, "applied");
|
|
|
|
|
assert.equal(applied.state.answered_probes.at(-1)?.probe_id, probe.id);
|
|
|
|
|
assert.equal(applied.state.answered_probes.at(-1)?.answer_class, "yes");
|
|
|
|
|
|
|
|
|
|
const deduplicated = withNakshatraBoundaryProbe(applied.state, boundary);
|
|
|
|
|
assert.ok(deduplicated);
|
|
|
|
|
assert.equal(deduplicated.probes.some((item) => item.source === "nakshatra_boundary"), false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("nonterminal invariant 3: answer_choice response waits until incident fallback focus is persisted", async () => {
|
|
|
|
|
const evidence = Array.from({ length: 7 }, (_, index) => ({
|
|
|
|
|
id: `44444444-4444-4444-8444-${String(index + 10).padStart(12, "0")}`,
|
|
|
|
|
source_turn_id: "33333333-3333-4333-8333-333333333333",
|
|
|
|
|
subject: "self",
|
|
|
|
|
event_kind: "dated_event",
|
|
|
|
|
domain: ["career", "relationship", "education"][index % 3]!,
|
|
|
|
|
occurred_from: `${2016 + index}-01-01`,
|
|
|
|
|
occurred_to: null,
|
|
|
|
|
date_precision: "year",
|
|
|
|
|
summary: `incident evidence ${index + 1}`,
|
|
|
|
|
status: "confirmed",
|
|
|
|
|
supersedes_evidence_id: null,
|
|
|
|
|
created_at: "2026-09-01T00:00:00.000Z",
|
|
|
|
|
}));
|
|
|
|
|
const answeredProbes = Array.from({ length: 6 }, (_, index) => ({
|
|
|
|
|
id: `incident-answered-${index + 1}`,
|
|
|
|
|
semantic_key: `incident.answered.${index + 1}`,
|
|
|
|
|
candidate_split_hash: `incident-answered-split-${index + 1}`,
|
|
|
|
|
domain: ["career", "relationship", "education"][index % 3]!,
|
|
|
|
|
year: 2016 + index,
|
|
|
|
|
question: `incident answered question ${index + 1}`,
|
|
|
|
|
candidate_ids: ["04:51", "05:03", "05:15"],
|
|
|
|
|
expected_outcomes: [
|
|
|
|
|
{ answer_class: "yes" as const, supports: ["04:51"], conflicts: ["05:15"] },
|
|
|
|
|
{ answer_class: "no" as const, supports: ["05:15"], conflicts: ["04:51"] },
|
|
|
|
|
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
|
|
|
|
|
],
|
|
|
|
|
information_gain: 0.5,
|
|
|
|
|
source: "dasha_boundary",
|
|
|
|
|
}));
|
|
|
|
|
const droppedProbes = Array.from({ length: 5 }, (_, index) => ({
|
|
|
|
|
id: `incident-dropped-${index + 1}`,
|
|
|
|
|
semantic_key: `varga.d${index + 2}.incident`,
|
|
|
|
|
candidate_split_hash: `incident-dropped-split-${index + 1}`,
|
|
|
|
|
domain: "career",
|
|
|
|
|
year: 0,
|
|
|
|
|
question: `yearless varga contrast ${index + 1}`,
|
|
|
|
|
candidate_ids: ["04:51", "05:03", "05:15"],
|
|
|
|
|
expected_outcomes: [
|
|
|
|
|
{ answer_class: "yes" as const, supports: ["04:51"], conflicts: ["05:15"] },
|
|
|
|
|
{ answer_class: "no" as const, supports: ["05:15"], conflicts: ["04:51"] },
|
|
|
|
|
],
|
|
|
|
|
information_gain: 1 + index / 10,
|
|
|
|
|
source: "varga_contrast",
|
|
|
|
|
}));
|
|
|
|
|
const incidentInference = buildInferenceState({
|
|
|
|
|
range_start: "04:51",
|
|
|
|
|
range_end: "05:15",
|
|
|
|
|
candidates: [
|
|
|
|
|
{ id: "04:51", time: "04:51", relative_support: 34 },
|
|
|
|
|
{ id: "05:03", time: "05:03", relative_support: 33 },
|
|
|
|
|
{ id: "05:15", time: "05:15", relative_support: 32 },
|
|
|
|
|
],
|
|
|
|
|
events: evidence.map((item) => ({
|
|
|
|
|
id: item.id,
|
|
|
|
|
domain: item.domain,
|
|
|
|
|
year: Number(item.occurred_from.slice(0, 4)),
|
|
|
|
|
precision: "year" as const,
|
|
|
|
|
})),
|
|
|
|
|
probes: [...answeredProbes, ...droppedProbes],
|
|
|
|
|
answered_probes: answeredProbes.map((probe) => ({
|
|
|
|
|
probe_id: probe.id,
|
|
|
|
|
semantic_key: probe.semantic_key,
|
|
|
|
|
candidate_split_hash: probe.candidate_split_hash,
|
|
|
|
|
answer_class: "unsure" as const,
|
|
|
|
|
classified_from: "choice" as const,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
const raw = dossierFixture({
|
|
|
|
|
evidence,
|
|
|
|
|
evidenceCount: evidence.length,
|
|
|
|
|
latestResult: candidateSnapshotFixture({
|
|
|
|
|
selectionAllowed: true,
|
|
|
|
|
decisionReceipt: {
|
|
|
|
|
inference_state: incidentInference,
|
|
|
|
|
diagnostic_quality: { passed: false, margin_percent: 4.476 },
|
|
|
|
|
date_sensitivity_retention_rate: 0.2857,
|
|
|
|
|
oos_blind_prompts: [
|
|
|
|
|
{ domain: "family", user_meaning: "家里有没有结婚、添丁或住院这类记得住时间的事?", used_for_scoring: false },
|
|
|
|
|
{ domain: "health_pressure", user_meaning: "有没有记得住时间的健康压力事件?", used_for_scoring: false },
|
|
|
|
|
],
|
|
|
|
|
nakshatra_boundary: {
|
|
|
|
|
near_boundary: true,
|
|
|
|
|
user_meaning: "平时做决定时,哪一组节奏更像你?",
|
|
|
|
|
options: [
|
|
|
|
|
{ key: "A", time_bias: "earlier", traits: ["直接", "行动快"] },
|
|
|
|
|
{ key: "B", time_bias: "later", traits: ["克制", "先观察"] },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
candidates: [
|
|
|
|
|
{ candidate_id: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:51", relative_support: 34, tied_minute_count: 1 },
|
|
|
|
|
{ candidate_id: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:03", relative_support: 33, tied_minute_count: 1 },
|
|
|
|
|
{ candidate_id: "88888888-8888-4888-8888-888888888883", rank: 3, time: "05:15", relative_support: 32, tied_minute_count: 1 },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
const parsedIncident = parseV9CaseDossier(raw);
|
|
|
|
|
assert.ok(parsedIncident);
|
|
|
|
|
const decision = decideFromDossier(parsedIncident);
|
|
|
|
|
assert.equal(decision.canAdopt, false);
|
|
|
|
|
assert.notEqual(decision.nextAction, "offer_provisional_range");
|
|
|
|
|
|
|
|
|
|
let activeFocus: Record<string, unknown> | null = null;
|
|
|
|
|
let releaseWrite!: () => void;
|
|
|
|
|
const writeGate = new Promise<void>((resolve) => { releaseWrite = resolve; });
|
|
|
|
|
let markWriteStarted!: () => void;
|
|
|
|
|
const writeStarted = new Promise<void>((resolve) => { markWriteStarted = resolve; });
|
|
|
|
|
const accounting = fakeAccounting({
|
|
|
|
|
get_agentic_rectification_case_dossier: () => ({
|
|
|
|
|
...raw,
|
|
|
|
|
conversation_summary: {
|
|
|
|
|
...raw.conversation_summary,
|
|
|
|
|
active_focus: activeFocus,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
get_agentic_rectification_case_compute: () => computeFixture(),
|
|
|
|
|
set_agentic_rectification_conversation_focus: async (_fn, args) => {
|
|
|
|
|
markWriteStarted();
|
|
|
|
|
await writeGate;
|
|
|
|
|
activeFocus = {
|
|
|
|
|
id: FOCUS_ID,
|
|
|
|
|
case_id: CASE_ID,
|
|
|
|
|
question_id: args.p_question_id,
|
|
|
|
|
intent: args.p_intent,
|
|
|
|
|
target_evidence_id: args.p_target_evidence_id,
|
|
|
|
|
target_domain: args.p_target_domain,
|
|
|
|
|
target_kind: args.p_target_kind,
|
|
|
|
|
expected_answer_schema: args.p_expected_answer_schema,
|
|
|
|
|
status: "active",
|
|
|
|
|
asked_at: "2026-09-01T00:00:00.000Z",
|
|
|
|
|
resolved_at: null,
|
|
|
|
|
};
|
|
|
|
|
return { focus: activeFocus, idempotent: false };
|
|
|
|
|
},
|
|
|
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let responseVisible = false;
|
|
|
|
|
const responsePromise = awaitTurnExitBeforeResponse(
|
|
|
|
|
new Response(null, { status: 200 }),
|
|
|
|
|
() => finalizeSuccessfulTurnExit({
|
|
|
|
|
accounting: accounting.client,
|
|
|
|
|
userId: USER_ID,
|
|
|
|
|
caseId: CASE_ID,
|
|
|
|
|
action: "answer_choice",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
void responsePromise.then(() => { responseVisible = true; });
|
|
|
|
|
await writeStarted;
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
assert.equal(responseVisible, false, "answer_choice response resolved before focus persistence completed");
|
|
|
|
|
|
|
|
|
|
releaseWrite();
|
|
|
|
|
const response = await responsePromise;
|
|
|
|
|
const refreshed = parseV9CaseDossier({
|
|
|
|
|
...raw,
|
|
|
|
|
conversation_summary: {
|
|
|
|
|
...raw.conversation_summary,
|
|
|
|
|
active_focus: activeFocus,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
assert.ok(refreshed);
|
|
|
|
|
const currentQuestion = projectCurrentQuestion(refreshed.conversationSummary.activeFocus);
|
|
|
|
|
assert.equal(response.status, 200);
|
|
|
|
|
assert.equal(decision.canAdopt, false);
|
|
|
|
|
assert.ok(currentQuestion?.prompt);
|
|
|
|
|
assert.equal(currentQuestion.prompt, "家里有没有结婚、添丁或住院这类记得住时间的事?不记得具体日子也可以先说有没有。");
|
|
|
|
|
const historyTurn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
|
|
|
|
assert.equal(historyTurn?.args.p_request_id, FOCUS_ID);
|
|
|
|
|
assert.equal(historyTurn?.args.p_user_message, null);
|
|
|
|
|
assert.equal(historyTurn?.args.p_assistant_message, currentQuestion.prompt);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("public decision fields are derived from decideRectification", () => {
|
|
|
|
|
const fixtures = [
|
|
|
|
|
{
|
|
|
|
|