fix: gate rectification candidate adoption flow

This commit is contained in:
Jesse_Chen
2026-08-04 16:47:23 +08:00
parent 7ba3abe92d
commit 79fe2d6c69
14 changed files with 436 additions and 56 deletions
-11
View File
@@ -397,7 +397,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(candidate, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -405,7 +404,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
district_code: "130407",
}), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -416,7 +414,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
]) {
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(candidate, coordinatePatch), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -426,7 +423,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
birth_time_status: "accepted",
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -437,11 +433,9 @@ test("ordinary declaration edits clear stale candidate application but never ove
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...candidate,
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
}, { timezone_offset: 7 }), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -453,7 +447,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
rectification_case_id: null,
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -466,13 +459,11 @@ test("ordinary declaration edits clear stale candidate application but never ove
reported_birth_time: edited.reported_birth_time,
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(null, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -481,7 +472,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
birth_time_status: "accepted",
}, edited), {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
@@ -492,7 +482,6 @@ test("ordinary declaration edits clear stale candidate application but never ove
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...candidate,
active_birth_time: null,
birth_time: null,
birth_time_status: null,
reported_birth_time: edited.reported_birth_time,
}, edited), {});
@@ -7,6 +7,11 @@ const migration = readFileSync(
"utf8",
);
const preservationMigration = readFileSync(
new URL("../supabase/migrations/20260804020000_preserve_reported_birth_time_on_candidate_acceptance.sql", import.meta.url),
"utf8",
);
test("candidate acceptance migration adds accepted status and durable result ownership", () => {
assert.match(migration, /birth_time_status in \([\s\S]*'reported'[\s\S]*'assessing'[\s\S]*'rectifying'[\s\S]*'candidate'[\s\S]*'accepted'[\s\S]*'confirmed'[\s\S]*\)/);
assert.match(migration, /create table public\.agentic_rectification_results/);
@@ -65,3 +70,34 @@ test("profile declaration changes invalidate restored Agentic candidate results"
assert.match(migration, /set invalidated_at = pg_catalog\.now\(\)/);
assert.match(migration, /coalesce\(new\.birth_time_status, ''\) not in \('accepted', 'confirmed'\)/);
});
test("forward repair separates original declaration from the active chart time", () => {
const profileUpdate = preservationMigration.slice(
preservationMigration.indexOf("update public.profiles\n set active_birth_time"),
preservationMigration.indexOf("update public.agentic_rectification_results", preservationMigration.indexOf("update public.profiles\n set active_birth_time")),
);
assert.match(profileUpdate, /active_birth_time = p_time/);
assert.match(profileUpdate, /birth_time_status = v_status/);
assert.doesNotMatch(profileUpdate, /^\s*birth_time = p_time/m);
assert.doesNotMatch(profileUpdate, /reported_birth_time\s*=/);
assert.doesNotMatch(preservationMigration, /v_profile\.birth_time is distinct from v_result\.selected_time/);
});
test("forward repair removes legacy field mirroring and repairs already selected Agentic profiles", () => {
const guard = preservationMigration.slice(
preservationMigration.indexOf("create or replace function public.guard_birth_time_journey"),
preservationMigration.indexOf("update public.profiles", preservationMigration.indexOf("create or replace function public.guard_birth_time_journey")),
);
assert.doesNotMatch(guard, /new\.birth_time := new\.active_birth_time/);
assert.doesNotMatch(guard, /new\.active_birth_time := new\.birth_time/);
assert.match(preservationMigration, /p\.birth_time_status in \('accepted', 'confirmed'\)/);
assert.match(preservationMigration, /p\.active_birth_time is not distinct from selected\.selected_time/);
});
test("forward repair lets the user change a previously adopted candidate", () => {
assert.doesNotMatch(preservationMigration, /agentic_rectification_candidate_already_selected/);
assert.match(preservationMigration, /v_profile\.active_birth_time is distinct from v_result\.selected_time/);
assert.match(preservationMigration, /update public\.agentic_rectification_results[\s\S]*selected_time = p_time/);
});
+21 -2
View File
@@ -34,6 +34,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260728010000_conversational_event_semantics\.sql/);
assert.match(migration.stdout, /applied 20260728020000_rectification_agent_v5\.sql/);
assert.match(migration.stdout, /applied 20260804010000_agentic_rectification_candidate_acceptance\.sql/);
assert.match(migration.stdout, /applied 20260804020000_preserve_reported_birth_time_on_candidate_acceptance\.sql/);
assert.equal(
fixture.psql(`
@@ -248,8 +249,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"SET\n04:55:accepted:false",
);
assert.equal(
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') from public.profiles where id = '${userId}'`),
"04:55:accepted:05:00",
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') || ':' || coalesce(to_char(birth_time, 'HH24:MI'), 'null') from public.profiles where id = '${userId}'`),
"04:55:accepted:05:00:null",
);
assert.equal(
fixture.psqlAs(
@@ -265,6 +266,24 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
),
"SET\ntrue",
);
assert.equal(
fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
`set role service_role;
select (result ->> 'saved_time') || ':' || (result ->> 'status') || ':' || (result ->> 'idempotent')
from (
select public.accept_agentic_rectification_candidate(
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '05:07'
) as result
) accepted`,
),
"SET\n05:07:accepted:false",
);
assert.equal(
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') || ':' || coalesce(to_char(birth_time, 'HH24:MI'), 'null') from public.profiles where id = '${userId}'`),
"05:07:accepted:05:00:null",
);
assert.equal(
fixture.psql(`select invalidated_at is null from public.agentic_rectification_results where id = '33333333-3333-4333-8333-333333333333'`),
"t",
@@ -15,6 +15,11 @@ const route = readFileSync(
"utf8",
);
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const agent = readFileSync(
new URL("../src/mastra/agentic-rectification.ts", import.meta.url),
"utf8",
);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("birth-time rectification entry no longer routes through V4", () => {
assert.doesNotMatch(component, /loadActiveRectificationV4|RectificationV4Panel|transitionRectificationV4/);
@@ -67,6 +72,17 @@ test("account rehydration normalizes persisted ISO birth dates before completene
assert.match(profileReader, /const date = normalizePersistedBirthDate\(/);
});
test("candidate acceptance refreshes the profile result without overwriting an open draft", () => {
const refresh = page.slice(
page.indexOf("async function refreshAccount"),
page.indexOf("function updateSession"),
);
assert.match(refresh, /const nextProfile = readProfile\(latest\.profile\)/);
assert.match(refresh, /setProfile\(nextProfile\)/);
assert.doesNotMatch(refresh, /setProfileDraft/);
});
test("agent tool calls leave a final step for visible prose and never end silently", () => {
assert.match(route, /const agenticRectificationMaxSteps = 8/);
assert.match(route, /\{ maxSteps: agenticRectificationMaxSteps \}/);
@@ -110,12 +126,26 @@ test("candidate acceptance is non-billable and happens before consultation credi
assert.ok(acceptance >= 0 && reserve > acceptance);
});
test("candidate state streams before done and renders relative support controls", () => {
test("candidate state streams before done and renders reusable multi-column choices", () => {
assert.match(route, /send\(\{ type: "candidates", result: candidateResult \}\)[\s\S]*send\(\{ type: "done", emitted: true \}\)/);
assert.match(chat, /fetch\(`\/api\/rectification\/agent\?sessionId=/);
assert.match(chat, /action: "accept_candidate"/);
assert.match(chat, /相对支持度仅用于本次候选比较,不是统计概率/);
assert.match(chat, /采用 \$\{candidate\.time\}/);
assert.match(chat, /当前可能的出生时间/);
assert.match(chat, /可以先采用一个作为当前排盘时间,也可以继续补充事件/);
assert.match(chat, /新增证据后,候选和相对支持度会重新计算/);
assert.match(chat, /相对支持度不是统计概率/);
assert.match(chat, /改选为此时间/);
assert.doesNotMatch(chat, /disabled=\{Boolean\(candidateResult\.selectedTime\)/);
assert.match(styles, /\.rectification-candidate-list \{[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/);
assert.match(styles, /\.rectification-candidate-list \{[\s\S]*min-width: 0/);
assert.match(chat, /当前推荐/);
assert.match(chat, /已采用/);
});
test("Agent cannot offer a candidate selection in the same turn that asks for more evidence", () => {
assert.match(agent, /offer_selection/);
assert.match(agent, /If you will ask for another event or date detail in the same reply, offer_selection must be false/);
assert.match(agent, /Never both ask for more evidence and offer candidate adoption in the same reply/);
});
test("stream failures remove empty assistant placeholders", () => {
@@ -406,15 +406,48 @@ test("confirm distinguishes skipped external validation from a failed VedAstro r
const result = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: sampleEvents,
offer_selection: false,
});
assert.equal(result.selection_allowed, true);
assert.equal(result.selection_allowed, false);
assert.equal(result.external_validation_status, "not_evaluated");
assert.equal(result.external_validation_invoked, false);
assert.equal(result.external_validation_reason, "local_candidate_not_ready_for_external_validation");
engine.restore();
});
test("confirm offers non-unique candidates only when the Agent explicitly ends evidence collection", async () => {
const engine = installEngine([{
path: "/api/active_rectification_events",
respond: () => {
const response = confirmedEngineResponse();
return {
...response,
body: {
...response.body,
can_apply: false,
technique_contract: {
confirmation_allowed: false,
decision: "continue_rectification",
external_engines: { status: "not_evaluated" },
},
},
};
},
}]);
const tools = createAgenticRectificationTools(makeCtx());
const result = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: sampleEvents,
offer_selection: true,
});
assert.equal(result.selection_allowed, true);
engine.restore();
});
test("accept candidate tool delegates the exact persisted candidate and preserves accepted status", async () => {
const calls: Array<{ time: string; resultId?: string }> = [];
const tools = createAgenticRectificationTools(makeCtx(async (time, resultId) => {