test(rectification): cover v9 migration rollout and regressions

- New suites: entry routing (13), evidence (11), skill/agent (13), stream (9),
  status/security (11) plus shared fake-accounting support.
- Migration static contract tests extended for the v9 agent api migration
  (run_phases, dossier/finalize/persist/accept/confirm RPCs, consent gate,
  needs_rebaseline guard, runtime flag, identity-foundation boundary).
- database-local-business.test.ts ledger + exact public table set updated
  (agentic_rectification_run_phases; BUG-127/BUG-144 boundary).
- rectification-v9-database.test.ts adds the agent-api migration test
  (flag seed, turn finalize, run phase receipt, consent rejection).
- rectification-agentic-entry.test.ts rewritten: the old tests locked in the
  hasRectificationSession + sessions.find guessing, textStream consumption and
  client-side session creation; they now assert the server-owned Case open
  flow, fullStream NDJSON, durable turns and exact-session routing.
- consultation-entrypoint / application-billing-contract rectification sections
  updated to the caseId-bound billing and open API contracts.
- docs/BUG_HISTORY.md: BUG-162 follow-up with regression record explaining why
  the old tests missed the broken entry guessing.
This commit is contained in:
Jesse
2026-08-11 17:18:08 +08:00
parent ca7484666f
commit 724fb64c1a
13 changed files with 2007 additions and 121 deletions
@@ -9,11 +9,11 @@ const consultRoute = readFileSync(new URL("src/app/api/consult/route.ts", root),
const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8");
test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => {
assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version"\)/);
assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(rectificationRoute, /modelConfigVersion: selectedModel\.configVersion/);
assert.doesNotMatch(rectificationRoute, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
assert.match(rectificationRoute, /const billingRequestPrefix = `rectification:\$\{sessionId\}`/);
assert.match(rectificationRoute, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/);
assert.match(rectificationRoute, /from\("usage_reservations"\)[\s\S]*\.eq\("feature_key", "rectification"\)[\s\S]*\.like\("request_id", `\$\{billingRequestPrefix\}%`\)/);
assert.match(rectificationRoute, /return reservations.length === 0[\s\S]*`\$\{billingRequestPrefix\}:retry:\$\{reservations.length\}`/);
assert.match(rectificationRoute, /authorizeUsage\(accounting, \{[\s\S]*requestId: billingRequestId/);
+33 -46
View File
@@ -78,13 +78,14 @@ test("ordinary product drafts keep the public question and clear hidden routing
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
});
test("homepage birth-time card opens the latest Agentic surface instead of ordinary consultation", () => {
test("homepage birth-time card opens the V9 Agentic surface via the server case API", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.match(source, /function openBirthTimeRectification/);
assert.match(source, /function openRectificationCase/);
assert.match(source, /openRectificationFromHomepage/);
assert.match(source, /<ConversationalBirthTimeRectification/);
assert.match(component, /<AgenticRectificationChat \{\.\.\.props\} \/>/);
assert.match(component, /<RectificationAgenticChat \{\.\.\.props\} \/>/);
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
@@ -96,40 +97,38 @@ test("homepage mounts the Agentic surface without invoking retired rectification
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(page, /<ConversationalBirthTimeRectification/);
assert.match(component, /<AgenticRectificationChat/);
assert.match(chat, /void send\(\{ action: "opening" \}, false\)/);
assert.match(component, /<RectificationAgenticChat/);
assert.match(chat, /void send\("opening", ""\)/);
});
test("homepage creates a new dedicated session before the Agent surface starts", () => {
test("homepage opens through the server Case API and merges the returned session", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleRectificationProfileIncomplete", start);
const start = source.indexOf("async function openRectificationCase");
const end = source.indexOf("async function openRectificationFromHomepage", start);
const handler = source.slice(start, end);
const create = handler.indexOf('createSession(modelCatalog.defaultModelId, "birth_time_rectification")');
const persist = handler.indexOf("await rectificationPersistence.current.enqueue");
const addToSessionList = handler.indexOf("setSessions((current) => [", persist);
const reveal = handler.indexOf("setActiveSessionId(rectificationSession.id)");
const open = handler.indexOf('/api/rectification/cases/open');
const merge = handler.indexOf('setSessions((current) => [merged,');
const reveal = handler.indexOf('setActiveSessionId(opened.sessionId)');
assert.ok(create >= 0);
assert.ok(persist > create);
assert.ok(addToSessionList > persist);
assert.ok(reveal > addToSessionList);
assert.match(handler, /rectificationOpenInFlight\.current/);
assert.ok(open >= 0);
assert.ok(merge > open);
assert.ok(reveal > merge);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.doesNotMatch(handler, /onNarrativeDelta/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
assert.match(source, /rectificationSurfaceOpen && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
assert.match(source, /rectificationSurfaceOpen && rectificationCaseId && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
});
test("the page persists only the dedicated session shell while the Agent owns opening", () => {
test("the page never creates the session shell locally; the server owns session creation", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleRectificationProfileIncomplete", start);
const start = page.indexOf("async function openRectificationCase");
const end = page.indexOf("async function openRectificationFromHomepage", start);
const handler = page.slice(start, end);
assert.match(handler, /persistSession\(rectificationSession, "create"\)/);
assert.match(component, /<AgenticRectificationChat/);
assert.doesNotMatch(handler, /persistSession\(rectificationSession, "create"\)/);
assert.match(handler, /const merged: ChatSession = \{/);
assert.match(component, /<RectificationAgenticChat/);
});
test("rectification cards render only inside the active rectification session", () => {
@@ -141,7 +140,7 @@ test("rectification cards render only inside the active rectification session",
assert.doesNotMatch(source, /这个会话保存了生时校正入口|恢复生时校正<\/button>/);
});
test("selecting a rectification session resumes it without an intermediate confirmation", () => {
test("selecting a rectification session resumes it through the exact-session open API", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const selectSession = source.slice(
source.indexOf("function selectSession("),
@@ -149,36 +148,24 @@ test("selecting a rectification session resumes it without an intermediate confi
);
assert.match(selectSession, /nextSession\?\.sessionType === "birth_time_rectification"/);
assert.match(selectSession, /resumeRectificationSession\.current\(nextSession\)/);
assert.match(selectSession, /void openRectificationSession\(nextSession\.id\)/);
assert.match(source, /resumeRectificationSession\.current\(activeSession\)/);
assert.doesNotMatch(source, /RectificationLoadingState|重试恢复/);
assert.match(source, /<ConversationalBirthTimeRectification/);
});
test("homepage reuses the dedicated rectification session for the Agentic surface", () => {
test("homepage restart and sidebar selection both resolve through the server open API", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleRectificationProfileIncomplete", start);
const handler = page.slice(start, end);
assert.match(handler, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
assert.match(handler, /existing \?\? createSession/);
assert.match(component, /<AgenticRectificationChat/);
assert.match(page, /openRectificationCase\(\"session\", exactSessionId, null\)/);
assert.match(page, /openRectificationCase\(\"new\", null, null\)/);
assert.doesNotMatch(page, /sourceSession\.sessionType === "birth_time_rectification"/);
assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
assert.match(component, /<RectificationAgenticChat/);
});
test("a bound rectification session and homepage restart share the same Agentic session shell", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleRectificationProfileIncomplete", start);
const handler = page.slice(start, end);
assert.match(handler, /sourceSession\.sessionType === "birth_time_rectification"[\s\S]*?sourceSession[\s\S]*?sessions\.find/);
assert.match(component, /<AgenticRectificationChat/);
});
test("rectify-first suggestions hand the source question to a dedicated rectification session", () => {
test("rectify-first suggestions hand the source question to the homepage open flow", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("function chooseConversationSuggestion");
const end = source.indexOf("function draftDailyStarlanguageQuestion", start);
@@ -186,7 +173,7 @@ test("rectify-first suggestions hand the source question to a dedicated rectific
assert.match(handler, /suggestion !== rectifyBeforeConsultationSuggestion/);
assert.match(handler, /find\(\(message\) => message\.role === "user"\)/);
assert.match(handler, /openBirthTimeRectification\(originalQuestion, activeSession\)/);
assert.match(handler, /openRectificationFromHomepage\(originalQuestion \?\? null\)/);
assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/);
});
@@ -196,7 +183,7 @@ test("rectify-first handoffs stay as Agent context", () => {
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/);
assert.match(chat, /之后回到你原来的问题/);
assert.match(chat, /之后回到你原来的问题/);
});
test("ordinary consultation uses current birth data without a rectification notice", () => {
@@ -49,6 +49,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260806050000_operations_feature_flags\.sql/);
assert.match(migration.stdout, /applied 20260811010000_consultation_status_service_role_read\.sql/);
assert.match(migration.stdout, /applied 20260812010000_agentic_rectification_v9_runtime\.sql/);
assert.match(migration.stdout, /applied 20260813010000_agentic_rectification_v9_agent_api\.sql/);
assert.equal(
fixture.psql(`
@@ -113,6 +114,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"agentic_rectification_evidence",
"agentic_rectification_open_ledger",
"agentic_rectification_results",
"agentic_rectification_run_phases",
"agentic_rectification_tool_receipts",
"agentic_rectification_turns",
"billing_products",
@@ -21,27 +21,35 @@ const agent = readFileSync(
);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("birth-time rectification entry mounts the Agentic chat", () => {
assert.match(component, /return <AgenticRectificationChat \{\.\.\.props\} \/>/);
test("birth-time rectification entry mounts the V9 case-ref chat", () => {
assert.match(component, /return <RectificationAgenticChat \{\.\.\.props\} \/>/);
assert.match(component, /caseId: string;/);
assert.match(component, /readonly: boolean;/);
assert.match(component, /shouldStartOpening: boolean;/);
assert.match(component, /initialTurns:/);
});
test("opening is a server-owned operation rather than a hidden user prompt", () => {
test("opening is server-owned: shouldStartOpening drives the first turn, never client history", () => {
assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/);
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
assert.match(chat, /action: "opening"/);
assert.match(route, /z\.literal\("opening"\)/);
assert.match(route, /conversation\.action === "opening"/);
assert.match(chat, /shouldStartOpening/);
assert.match(chat, /if \(readonly \|\| openingStarted\.current \|\| !shouldStartOpening\) return/);
assert.match(chat, /void send\("opening", ""\)/);
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only"\]\)/);
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
});
test("incomplete profiles stay in the shared onboarding flow", () => {
test("incomplete profiles stay in the shared onboarding flow before any open request", () => {
const opening = page.slice(
page.indexOf("async function openBirthTimeRectification"),
page.indexOf("resumeRectificationSession.current ="),
page.indexOf("async function openRectificationCase"),
page.indexOf("async function openRectificationFromHomepage"),
);
assert.match(opening, /const missingStep = missingProfileStep\(profile\)/);
assert.match(opening, /setOnboardingStep\(missingStep\)/);
assert.ok(
opening.indexOf("const missingStep = missingProfileStep(profile)")
< opening.indexOf("setRectificationSessionId(rectificationSession.id)"),
< opening.indexOf("/api/rectification/cases/open"),
);
assert.match(chat, /payload\?\.code === "profile_incomplete"/);
});
@@ -71,7 +79,6 @@ 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"),
@@ -82,75 +89,72 @@ test("candidate acceptance refreshes the profile result without overwriting an o
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 \}/);
assert.match(route, /if \(!emitted \|\| !reply\.text\) \{[\s\S]*type: "error"[\s\S]*await settle\(false\)[\s\S]*return;/);
assert.doesNotMatch(route, /send\(\{ type: "done", emitted \}\)/);
test("agent tool calls never end silently; the runner owns completion and failure", () => {
assert.doesNotMatch(route, /agenticRectificationMaxSteps = 8/);
assert.match(agent, /RECTIFICATION_AGENT_STEP_BUDGETS/);
assert.match(agent, /RECTIFICATION_AGENT_HARD_STEP_LIMIT/);
assert.match(route, /send\(\{ type: "done", emitted: true \}\)/);
assert.doesNotMatch(route, /send\(\{ type: "done", emitted: false \}\)/);
});
test("rectification messages survive remounts and suppress duplicate openings", () => {
assert.match(chat, /initialMessages: readonly ChatMessage\[\]/);
assert.match(chat, /if \(initialMessages\.length > 0 \|\| openingStarted\.current\) return/);
assert.match(chat, /sessionId,/);
assert.match(chat, /onMessagesChange\?\.\(/);
assert.match(page, /key=\{rectificationSessionId\}/);
assert.match(page, /initialMessages=\{activeSession\?\.messages \?\? \[\]\}/);
test("persisted turns survive remounts; duplicate openings are suppressed by the server", () => {
assert.match(chat, /initialTurns/);
assert.match(chat, /const openingStarted = useRef\(false\)/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
});
test("successful Agent turns are persisted by the authenticated rectification route", () => {
test("the agent route verifies the exact Case/Session binding before any turn", () => {
assert.match(route, /caseId: z\.string\(\)\.uuid\(\)/);
assert.match(route, /sessionId: z\.string\(\)\.uuid\(\)/);
assert.match(route, /\.from\("chat_sessions"\)[\s\S]*\.eq\("user_id", userId\)/);
assert.match(route, /conversation\.action === "opening" && persistedMessages\.length > 0/);
assert.match(route, /\.update\(\{ messages: nextMessages, updated_at:/);
assert.match(route, /if \(saveError \|\| !savedSession\) throw new Error\("RectificationSessionPersistenceError"\)/);
assert.match(route, /requestId: z\.string\(\)\.uuid\(\)/);
assert.match(route, /boundSessionId !== sessionId/);
assert.match(route, /chatSession\.agentic_rectification_case_id !== caseId/);
assert.doesNotMatch(route, /conversation\.action === "opening" && persistedMessages\.length > 0/);
assert.doesNotMatch(route, /\.update\(\{ messages: nextMessages, updated_at:/);
});
test("candidate results restore through the durable Candidate Snapshot API, never agent text", () => {
assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\?sessionId=/);
assert.doesNotMatch(chat, /savedSentinel|AYANAM_RECTIFICATION_SAVED|hidden block/);
assert.doesNotMatch(chat, /<!--AYANAM_SUGGESTIONS/);
});
test("candidate results restore only through the authenticated rectification session", () => {
const getRoute = route.slice(
route.indexOf("export async function GET"),
route.indexOf("export async function POST"),
test("candidate acceptance is non-billable and happens through the durable case endpoint", () => {
assert.match(chat, /\/candidates\/accept/);
assert.match(chat, /resultId: candidateResult\.resultId/);
assert.doesNotMatch(chat, /action: "accept_candidate"/);
const acceptRoute = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
"utf8",
);
assert.match(getRoute, /sessionId/);
assert.match(getRoute, /\.eq\("user_id", user\.id\)/);
assert.match(getRoute, /session\.session_type !== "birth_time_rectification"/);
assert.match(getRoute, /loadLatestAgenticRectificationResult\(accounting, user\.id, sessionId\)/);
assert.match(acceptRoute, /accept_agentic_rectification_candidate_for_case/);
assert.doesNotMatch(acceptRoute, /authorizeUsage|completeUsage|begin_consultation_credit/);
});
test("candidate acceptance is non-billable and happens before unified usage authorization", () => {
const acceptance = route.indexOf('parsed.data.action === "accept_candidate"');
const authorize = route.indexOf("authorizeUsage(accounting");
assert.ok(acceptance >= 0 && authorize > acceptance);
test("usage completes or releases without hiding settlement failures", () => {
const run = readFileSync(
new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url),
"utf8",
);
assert.match(run, /billing\.complete\(/);
assert.match(run, /billing\.release\(/);
assert.match(run, /usage_settlement_failed/);
assert.match(route, /featureKey: "rectification"/);
assert.doesNotMatch(route, /begin_consultation_credit|complete_consultation_credit|cancel_consultation_credit/);
assert.match(route, /rectification:case:\$\{caseId\}/);
});
test("Agentic rectification completes or releases unified usage without hiding settlement failures", () => {
assert.match(route, /settlement = await completeUsage\(accounting, userId, billingRequestId, \{/);
assert.match(route, /eventKey: requestId,/);
assert.match(route, /actualModelId: selectedModel\.id/);
assert.match(route, /modelConfigVersion: selectedModel\.configVersion/);
assert.match(route, /inputTokens,/);
assert.match(route, /outputTokens,/);
assert.match(route, /costMicrousd: Math\.round/);
assert.match(route, /durationMs: Date\.now\(\) - usageStartedAt/);
assert.match(route, /settlement = await releaseUsage\(accounting, userId, billingRequestId, "rectification_cancelled"\)/);
assert.match(route, /if \(!settlement\.success\) throw new Error\(settlement\.error_code \?\? "usage_settlement_failed"\)/);
assert.match(route, /if \(!await settle\(true, result\.totalUsage\)\)[\s\S]*type: "error"[\s\S]*return;[\s\S]*send\(\{ type: "done", emitted: true \}\)/);
});
test("opening and message retries reuse the caller-owned turn request as their usage event key", () => {
assert.match(route, /const requestId = conversation\.requestId;/);
assert.match(route, /completeUsage\(accounting, userId, billingRequestId, \{[\s\S]*eventKey: requestId,/);
test("opening and message retries reuse the caller-owned request id as the usage event key", () => {
assert.match(route, /requestId,\s*action\s*\} = parsed\.data/);
assert.match(route, /eventKey: requestId/);
assert.doesNotMatch(route, /eventKey:\s*(?:globalThis\.)?crypto\.randomUUID\(\)/);
});
test("Agentic rectification uses one case-level entitlement and the session-pinned model version", () => {
assert.match(route, /select\("id,messages,session_type,model_id,model_config_version"\)/);
test("rectification uses one case-level entitlement and the session-pinned model version", () => {
assert.match(route, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
assert.match(route, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(route, /const billingRequestPrefix = `rectification:\$\{sessionId\}`/);
assert.match(route, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/);
assert.match(route, /requestId: billingRequestId/);
assert.match(route, /modelConfigVersion: selectedModel\.configVersion/);
assert.doesNotMatch(route, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
@@ -165,13 +169,9 @@ test("Agentic rectification scrolls the conversation container as streamed messa
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
});
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"/);
test("candidate state renders from the snapshot API and never from sentinels", () => {
assert.match(chat, /当前可能的出生时间/);
assert.match(chat, /可以先采用一个作为当前排盘时间,也可以继续补充事件/);
assert.match(chat, /新增证据后,候选和相对支持度会重新计算/);
assert.match(chat, /相对支持度不是统计概率/);
assert.match(chat, /改选为此时间/);
assert.doesNotMatch(chat, /disabled=\{Boolean\(candidateResult\.selectedTime\)/);
@@ -181,10 +181,15 @@ test("candidate state streams before done and renders reusable multi-column choi
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("the Agent prompt cannot offer candidates while asking for more evidence", () => {
// The hard boundary lives in the prompt; no tool input carries an
// offer_selection boolean anymore.
assert.match(agent, /不得在同一回复里一边要求继续补证据、一边提供候选采用/);
const tools = readFileSync(
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(tools, /offer_selection/);
});
test("stream failures remove empty assistant placeholders", () => {
@@ -194,7 +199,42 @@ test("stream failures remove empty assistant placeholders", () => {
assert.match(chat, /if \(succeeded\)/);
});
test("new rectification sessions are created before the Agent surface mounts", () => {
assert.match(page, /await rectificationPersistence\.current\.enqueue[\s\S]*setRectificationSessionId\(rectificationSession\.id\)/);
assert.doesNotMatch(page, /setRectificationSessionId\(rectificationSession\.id\)[\s\S]{0,500}persistSession\(rectificationSession, "create"\)/);
test("rectification sessions are opened by the server Case API; the browser never creates them", () => {
assert.match(page, /\/api\/rectification\/cases\/open/);
assert.match(page, /openRectificationRequestBody\(intent, exactSessionId\)/);
assert.match(page, /openResponseFromPayload\(payload\)/);
assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
assert.doesNotMatch(page, /hasRectificationSession/);
assert.match(page, /rectificationCardAction = resolveRectificationEntryAction/);
});
test("sidebar selection passes the exact sessionId to the server open API", () => {
const selectSession = page.slice(
page.indexOf("function selectSession("),
page.indexOf("async function selectSessionModel", page.indexOf("function selectSession(")),
);
assert.match(selectSession, /nextSession\?\.sessionType === "birth_time_rectification"/);
assert.match(selectSession, /void openRectificationSession\(nextSession\.id\)/);
assert.doesNotMatch(selectSession, /resumeRectificationSession\.current\(nextSession\)/);
});
test("homepage CTA is server-driven from the entry summary, not the session list", () => {
const entryLib = readFileSync(
new URL("../src/lib/rectification-entry.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(page, /hasRectificationSession/);
assert.doesNotMatch(page, /sessions\.some\([\s\S]{0,120}birth_time_rectification/);
assert.match(page, /resolveRectificationEntryAction/);
assert.match(page, /rectificationCardAction === "resume"/);
assert.match(page, /rectificationCardAction === "restart"/);
assert.match(entryLib, /开始生时校正/);
assert.match(entryLib, /继续上次校正/);
assert.match(entryLib, /再次校正/);
});
test("readonly terminal sessions expose a 再次校正 action instead of appending", () => {
assert.match(chat, /readonly/);
assert.match(chat, /再次校正/);
assert.match(chat, /onRestart/);
});
@@ -0,0 +1,393 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
RECTIFICATION_AGENT_HARD_STEP_LIMIT,
RECTIFICATION_AGENT_MAX_STEPS,
RECTIFICATION_AGENT_STEP_BUDGETS,
RECTIFICATION_V9_SKILL_PATH,
RECTIFICATION_V9_SKILL_NAME,
getRectificationV9Agent,
resolveRectificationStepBudget,
} from "../src/mastra/agentic-rectification.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
SESSION_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
RESULT_ID,
} from "./rectification-v9-test-support.ts";
const agentSource = readFileSync(
fileURLToPath(new URL("../src/mastra/agentic-rectification.ts", import.meta.url)),
"utf8",
);
test("system prompt carries only high-priority boundaries, never the method copy", () => {
const promptStart = agentSource.indexOf("const agenticRectificationInstructions");
const promptEnd = agentSource.indexOf("export function getRectificationV9Agent");
const prompt = agentSource.slice(promptStart, promptEnd);
// No gate -> scan -> score -> diagnostics orchestration in the prompt.
assert.doesNotMatch(prompt, /rectification-gate[\s\S]*rectification-scan/);
assert.doesNotMatch(prompt, /rectification-score[\s\S]*rectification-diagnostics/);
assert.doesNotMatch(prompt, /rectification-confirm[\s\S]*rectification-save-birth-time/);
assert.doesNotMatch(prompt, /80%\/60%/);
assert.doesNotMatch(prompt, /10[-]15 个事件/);
assert.doesNotMatch(prompt, /D9\/D10 类型表/);
assert.doesNotMatch(prompt, /run the required gate/);
assert.doesNotMatch(prompt, /candidate_range/);
assert.match(prompt, /jyotish-birth-time-rectification/);
// Keep the prompt short (~30 lines max).
assert.ok(prompt.split("\n").length <= 60, "instructions must stay bounded");
});
test("agent pins the dedicated rectification skill and its fixed version", () => {
assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification");
assert.ok(RECTIFICATION_V9_SKILL_PATH.endsWith("skills/jyotish-birth-time-rectification"));
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(RECTIFICATION_SKILL_VERSION, "9.0.0");
});
test("step budgets are bounded per action with a hard ceiling", () => {
assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.opening, 6);
assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.evidence, 8);
assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.rescore, 12);
assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.accept, 6);
assert.equal(RECTIFICATION_AGENT_MAX_STEPS, 12);
for (const action of ["opening", "read_only", "evidence", "rescore", "accept", "confirm"]) {
const budget = resolveRectificationStepBudget(action as keyof typeof RECTIFICATION_AGENT_STEP_BUDGETS);
assert.ok(budget <= RECTIFICATION_AGENT_HARD_STEP_LIMIT, `${action} must respect the hard ceiling`);
}
assert.equal(resolveRectificationStepBudget("rescore"), 12);
});
test("no tool accepts event arrays, birth data or candidate ranges as input", () => {
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: fakeAccounting({}).client as never,
});
const toolNames = [
"rectification-read-case",
"rectification-propose-evidence",
"rectification-confirm-evidence",
"rectification-revise-evidence",
"rectification-compare-candidates",
"rectification-read-diagnostics",
"rectification-offer-candidates",
"rectification-accept-candidate",
"rectification-confirm-birth-time",
"rectification-close-case",
];
for (const name of toolNames) {
const tool = tools[name as keyof typeof tools] as unknown as { inputSchema: { safeParse(value: unknown): { success: boolean } } };
assert.ok(tool?.inputSchema, `${name} must expose an input schema`);
const malicious = {
caseId: CASE_ID,
userId: USER_ID,
birth_date: "1997-08-08",
latitude: 36.4,
longitude: 114.2,
timezone_offset: 8,
candidate_range: { start_time: "04:00", end_time: "06:00" },
events: [{ id: "e1", domain: "career", date: "2016-09" }],
scores: [10, 20],
confirmationAllowed: true,
};
const result = tool.inputSchema.safeParse(malicious);
assert.equal(result.success, false, `${name} must reject userId/birth/range/events/scores/permissions`);
}
});
type StreamChunk = {
type: string;
payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown };
};
type FakeStreamResult = {
fullStream: AsyncIterable<StreamChunk>;
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
};
function chunk(type: string, payload?: Record<string, unknown>): StreamChunk {
return { type, ...(payload ? { payload } : {}) };
}
function fakeAgentStream(chunks: StreamChunk[]) {
const streamResult: FakeStreamResult = {
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
};
return {
stream: async () => streamResult,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
function runOptions(overrides: Partial<V9AgentRunOptions> = {}): {
options: V9AgentRunOptions;
emitted: Array<{ type: string }>;
billing: { reserved: number; completed: number; released: number };
} {
const emitted: Array<{ type: string }> = [];
const billing = { reserved: 0, completed: 0, released: 0 };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
get_agentic_rectification_turn_receipt: () => null,
});
const optionsValue: V9AgentRunOptions = {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "2016年9月离开家去北京工作",
modelName: "gpt-4o-mini",
accounting: accounting.client,
billing: {
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
complete: async () => { billing.completed += 1; return true; },
release: async () => { billing.released += 1; return true; },
},
emit: (event) => { emitted.push(event); },
buildAgent: async () => fakeAgentStream([]) as never,
...overrides,
};
return { options: optionsValue, emitted, billing };
}
test("first turn with no real skill evidence retries once then fails without saving success", async () => {
const { options, emitted, billing } = runOptions({
accounting: fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0, turns: [] }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
}).client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好," }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.turnStatus, "failed");
assert.equal(result.skillLoaded, false);
assert.equal(result.errorCode, "skill_not_loaded");
assert.equal(billing.released, 1, "failed first turn must release usage");
assert.equal(billing.completed, 0);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
});
test("first turn with real skill.started/skill.loaded evidence completes and persists receipts", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0 }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好,我是生时校正助手。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.turnStatus, "completed");
assert.equal(result.skillLoaded, true);
assert.equal(billing.completed, 1);
assert.equal(billing.released, 0);
const types = emitted.map((event) => event.type);
assert.ok(types.includes("run.started"));
assert.ok(types.includes("skill.started"));
assert.ok(types.includes("skill.loaded"));
assert.ok(types.includes("case.loaded"));
assert.ok(types.includes("answer.delta"));
assert.ok(types.includes("run.completed"));
// Reasoning and raw chunks are dropped.
const accountingCalls = accounting.calls.map((call) => call.fn);
assert.ok(accountingCalls.includes("insert_agentic_rectification_run_phase"));
assert.ok(accountingCalls.includes("finalize_agentic_rectification_turn"));
});
test("framework getSkill failure is a controlled retry and then a failed turn", async () => {
const { options, billing } = runOptions({
buildAgent: async () => ({
stream: async () => ({ fullStream: (async function* () {})() }),
getSkill: async () => null,
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "skill_not_loaded");
assert.equal(billing.released, 1);
});
test("a repeated identical tool call is detected and aborts the turn", async () => {
const { options, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
...Array.from({ length: 4 }, () => chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } })),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "repeated_tool_call");
assert.equal(billing.released, 1);
});
test("a failed opening does not let the next turn skip the real skill gate", async () => {
// The dossier has one failed turn and no completed turn: the skill gate
// must still apply, so an agent that never invokes the skill tool fails.
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
turnCount: 1,
turns: [{
id: TURN_ID,
role: "user",
text: "(开场)",
status: "failed",
created_at: "2026-08-12T10:00:00.000Z",
completed_at: null,
}],
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
});
const { options, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好," }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "skill_not_loaded");
assert.equal(billing.released, 1);
});
test("same evidence + range fingerprints reuse the cached candidate snapshot", async () => {
const accounting = fakeAccounting({
persist_agentic_rectification_candidate: () => ({
...candidateSnapshotFixture(),
cached: true,
}),
});
const cached = await persistV9Candidate(accounting.client, USER_ID, CASE_ID, {
engineResultId: "engine-1",
algorithmVersion: "rectification-v5",
evidenceFingerprint: "b".repeat(64),
rangeFingerprint: "c".repeat(64),
skillVersion: "9.0.0",
candidateRange: CANDIDATE_RANGE,
candidates: [{ rank: 1, time: "05:02", relative_support: 58, tied_minute_count: 2 }],
overallConfidence: "medium",
marginPercent: 16,
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: null,
});
assert.equal(cached.cached, true);
assert.equal(cached.resultId, RESULT_ID);
const call = accounting.calls.find((item) => item.fn === "persist_agentic_rectification_candidate");
assert.ok(call);
assert.equal(call.args.p_evidence_ledger_fingerprint, "b".repeat(64));
assert.equal(call.args.p_skill_version, "9.0.0");
});
test("accept-candidate requires a server-persisted result; no tool means no minute", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: null }),
accept_agentic_rectification_candidate_for_case: () => {
throw new Error("agentic_rectification_candidate_not_found");
},
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
(tools["rectification-accept-candidate"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({
caseId: CASE_ID,
resultId: RESULT_ID,
candidateId: "05:02",
}),
(error: unknown) => error instanceof Error && error.message.includes("candidate_not_found"),
);
});
test("new evidence lets the agent choose diagnostics/compare tools autonomously", () => {
// The tool layer exposes read-diagnostics and compare-candidates; there is
// no hard-coded orchestration forcing a scan before score.
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: fakeAccounting({}).client as never,
});
assert.ok("rectification-compare-candidates" in tools);
assert.ok("rectification-read-diagnostics" in tools);
assert.ok("rectification-offer-candidates" in tools);
assert.ok(!("rectification-scan" in tools));
assert.ok(!("rectification-gate" in tools));
});
test("agent construction wires the pinned skill and the ten v9 tools", () => {
const model = {
id: "test-model",
label: "Test",
description: "",
creditCost: 1,
isDefault: true,
mode: "compatible" as const,
model: { provider: "openai", name: "gpt-4o-mini", modelId: "gpt-4o-mini" } as never,
};
const accounting = fakeAccounting({});
const agent = getRectificationV9Agent(model, {
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
assert.equal(agent.id, "rectification-v9-test-model");
assert.ok(agent);
});
@@ -759,3 +759,108 @@ test("v9 legacy backfill maps statuses, keeps one resumable per user and is idem
fixture.stop();
}
});
test("v9 agent api migration applies, seeds the runtime flag and guards consent", { skip: skipWithoutDocker }, async () => {
const fixture = startPostgresFixture();
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
});
assert.equal(migration.status, 0, migration.stderr);
assert.match(migration.stdout, /applied 20260813010000_agentic_rectification_v9_agent_api\.sql/);
// The runtime selector flag is published and enabled.
assert.equal(
fixture.psql(`select enabled || ':' || rollout_percentage || ':' || status
from public.feature_flags where flag_key = 'rectification_runtime_version'`),
"true:100:published",
);
// Run phases table exists with RLS.
assert.equal(
fixture.psql(`select count(*) from pg_tables where schemaname='public' and tablename='agentic_rectification_run_phases'`),
"1",
);
// Set up a profile + case + pending turn, then exercise the turn
// finalize and run-phase receipt RPCs.
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into public.profiles (id, birth_date, reported_birth_time, active_birth_time, birth_time_source,
uncertainty_before_minutes, uncertainty_after_minutes, latitude, longitude, timezone_offset)
values ('66666666-6666-4666-8666-666666666666', '1997-08-08', '05:00', null, 'family_exact',
10, 10, 36.420487, 114.209936, 8);
`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages)
values ('22222222-2222-4222-8222-222222222222', '66666666-6666-4666-8666-666666666666', '生时校正', 'general', 'birth_time_rectification', '[]'::jsonb);
`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into public.agentic_rectification_cases (
id, user_id, session_id, status, skill_name, skill_version,
baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range
) values (
'11111111-1111-4111-8111-111111111111', '66666666-6666-4666-8666-666666666666',
'22222222-2222-4222-8222-222222222222', 'draft', 'jyotish-birth-time-rectification', '9.0.0',
'a'.repeat(64),
'{"birth_date":"1997-08-08","latitude":36.420487,"longitude":114.209936,"timezone_offset":8,"birth_time_source":"family_exact"}'::jsonb,
'{"start_time":"04:50","end_time":"05:10"}'::jsonb
);
`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into public.agentic_rectification_turns (id, case_id, user_message, status, model_name)
values ('33333333-3333-4333-8333-333333333333', '11111111-1111-4111-8111-111111111111',
'2016年9月离开家去北京开始工作', 'pending', 'gpt-4o-mini');
`);
const service = createLocalPostgresDataClient(
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
null,
"service_role",
);
const finalize = await service.rpc("finalize_agentic_rectification_turn", {
p_user_id: "66666666-6666-4666-8666-666666666666",
p_case_id: "11111111-1111-4111-8111-111111111111",
p_turn_id: "33333333-3333-4333-8333-333333333333",
p_status: "completed",
p_assistant_message: "好的,我会先核对出生时间范围。",
});
assert.equal(finalize.error, null, rpcError(finalize.error));
const phase = await service.rpc("insert_agentic_rectification_run_phase", {
p_user_id: "66666666-6666-4666-8666-666666666666",
p_case_id: "11111111-1111-4111-8111-111111111111",
p_turn_id: "33333333-3333-4333-8333-333333333333",
p_phase: "skill.loaded",
p_tool_name: null,
p_sequence: 1,
});
assert.equal(phase.error, null, rpcError(phase.error));
const receipt = await service.rpc("get_agentic_rectification_turn_receipt", {
p_user_id: "66666666-6666-4666-8666-666666666666",
p_case_id: "11111111-1111-4111-8111-111111111111",
p_turn_id: "33333333-3333-4333-8333-333333333333",
});
assert.equal(receipt.error, null, rpcError(receipt.error));
const receiptRow = receipt.data as Record<string, unknown>;
assert.equal(receiptRow.status, "completed");
assert.equal(receiptRow.skill_version, "9.0.0");
// Consent-less confirmation is rejected even with a confirmable result.
const consent = await service.rpc("confirm_agentic_rectification_birth_time", {
p_user_id: "66666666-6666-4666-8666-666666666666",
p_case_id: "11111111-1111-4111-8111-111111111111",
p_result_id: "55555555-5555-4555-8555-555555555555",
p_time: "05:02",
p_consent_quote: "就用05:02",
p_source_turn_id: "33333333-3333-4333-8333-333333333333",
});
assert.equal(consent.error, null);
const consentRow = consent.data as { error?: unknown };
assert.equal(consentRow.error, "agentic_rectification_candidate_not_found");
} finally {
fixture.stop();
}
});
@@ -0,0 +1,254 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
entrySummaryFromResponse,
isResumableRectificationStatus,
isTerminalRectificationStatus,
openRectificationRequestBody,
openResponseFromPayload,
rectificationEntryLabels,
resolveRectificationEntryAction,
} from "../src/lib/rectification-entry.ts";
import {
openRectificationCaseRequestSchema,
shouldStartOpening,
} from "../src/lib/rectification-agentic/v9/open-request.ts";
import {
mapRectificationRpcError,
openRectificationCase,
RectificationCaseServiceError,
} from "../src/lib/rectification-agentic/v9/case-service.ts";
import {
CASE_ID,
SESSION_ID,
fakeAccounting,
type FakeRpcHandler,
} from "./rectification-v9-test-support.ts";
const uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
test("homepage entry with no history requests a create and shows 开始生时校正", () => {
const body = openRectificationRequestBody("homepage", null);
assert.equal(body.intent, "homepage");
assert.ok(typeof body.requestId === "string" && body.requestId.length > 0);
assert.equal("sessionId" in body, false);
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: false,
latest_resumable: null,
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "start");
assert.equal(rectificationEntryLabels.start, "开始生时校正");
});
test("homepage entry with a resumable case resumes and shows 继续上次校正", () => {
const summary = entrySummaryFromResponse({
has_resumable_case: true,
has_terminal_case_with_time: false,
latest_resumable: {
case_id: CASE_ID,
status: "collecting_evidence",
last_activity_at: "2026-08-12T10:00:00.000Z",
},
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "resume");
assert.equal(rectificationEntryLabels.resume, "继续上次校正");
const opened = openResponseFromPayload({
disposition: "resumed",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "collecting_evidence",
shouldStartOpening: false,
skillVersion: "9.0.0",
});
assert.equal(opened?.shouldStartOpening, false);
assert.equal(opened?.caseId, CASE_ID);
assert.equal(opened?.sessionId, SESSION_ID);
});
test("homepage entry with only a terminal case shows 再次校正 and creates a new case", () => {
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: true,
latest_resumable: null,
latest_terminal: {
case_id: CASE_ID,
status: "confirmed",
has_usable_time: true,
},
});
assert.equal(resolveRectificationEntryAction(summary), "restart");
assert.equal(rectificationEntryLabels.restart, "再次校正");
});
test("legacy completed sessions never derive 继续上次 from the session list", () => {
// The CTA must be driven by the server entry summary, not by scanning
// sessionType in the client session list.
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: false,
latest_resumable: null,
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "start");
assert.notEqual(rectificationEntryLabels.start, "继续上次校正");
});
test("sidebar click passes the exact sessionId and opens exactly that session", () => {
const body = openRectificationRequestBody("session", SESSION_ID);
assert.equal(body.intent, "session");
assert.equal(body.sessionId, SESSION_ID);
});
test("sidebar click on a terminal session opens read-only history", () => {
const opened = openResponseFromPayload({
disposition: "readonly",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "confirmed",
shouldStartOpening: false,
skillVersion: "9.0.0",
});
assert.equal(opened?.disposition, "readonly");
assert.equal(isTerminalRectificationStatus(opened?.status ?? ""), true);
assert.equal(isResumableRectificationStatus(opened?.status ?? ""), false);
assert.equal(opened?.shouldStartOpening, false);
});
test("double-click sends an idempotency key and never business state", () => {
const first = openRectificationRequestBody("homepage", null);
const second = openRectificationRequestBody("homepage", null);
// Both requests carry a requestId; the server serializes on the user and
// dedupes via the open ledger. No caseId/birth data/range is ever sent.
assert.ok(first.requestId);
assert.ok(second.requestId);
for (const body of [first, second]) {
assert.equal("caseId" in body, false);
assert.equal("birth_date" in body, false);
assert.equal("candidate_range" in body, false);
assert.equal("userId" in body, false);
}
});
test("two tabs produce distinct request ids; the open schema stays minimal", () => {
const tabA = openRectificationRequestBody("homepage", null);
const tabB = openRectificationRequestBody("homepage", null);
assert.notEqual(tabA.requestId, tabB.requestId);
for (const parsed of [tabA, tabB]) {
const result = openRectificationCaseRequestSchema.safeParse(parsed);
assert.equal(result.success, true);
}
});
test("non-owner sessionId maps to a 404 business error, never a leak", () => {
const view = mapRectificationRpcError(
new Error("agentic_rectification_session_not_found"),
);
assert.equal(view.status, 404);
assert.equal(view.code, "case_session_not_found");
});
test("profile incomplete prevents case creation with an explicit business error", async () => {
const accounting = fakeAccounting({});
// loadV9RectificationProfile throws profile_incomplete before the open RPC
// runs when the profile row is missing required fields.
const profileClient = {
...accounting.client,
from: () => ({
select: () => ({
eq: () => ({
single: async () => ({ data: { birth_date: null, latitude: null }, error: null }),
}),
}),
}),
} as never;
await assert.rejects(
openRectificationCase(profileClient, "user-1", {
intent: "homepage",
requestId: uuid,
}),
(error: unknown) => error instanceof RectificationCaseServiceError && error.code === "profile_incomplete",
);
});
test("shouldStartOpening is server-owned: only freshly created never-started cases", () => {
assert.equal(shouldStartOpening("created", 0), true);
assert.equal(shouldStartOpening("created", 1), false);
assert.equal(shouldStartOpening("resumed", 0), false);
assert.equal(shouldStartOpening("readonly", 3), false);
});
test("empty client message cache never repeats opening when the case has turns", () => {
// The client messages array may be empty after refresh, but the server
// returned a resumed case: shouldStartOpening stays false, so the opening
// turn is not re-emitted.
const opened = openResponseFromPayload({
disposition: "resumed",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "collecting_evidence",
shouldStartOpening: false,
skillVersion: "9.0.0",
});
assert.equal(opened?.shouldStartOpening, false);
});
test("open RPC passes the pinned skill and server-derived baseline only", async () => {
const rpc: FakeRpcHandler = (fn) => {
if (fn === "open_agentic_rectification_case") {
return {
disposition: "created",
case_id: CASE_ID,
session_id: SESSION_ID,
status: "draft",
should_start_opening: true,
skill_version: "9.0.0",
};
}
return null;
};
const profile = {
birth_date: "1997-08-08",
reported_birth_time: "05:00",
active_birth_time: null,
birth_time_source: "family_exact",
birth_time_period: null,
uncertainty_before_minutes: 10,
uncertainty_after_minutes: 10,
latitude: 36.420487,
longitude: 114.209936,
timezone_offset: 8,
};
const accounting = fakeAccounting({
open_agentic_rectification_case: rpc,
});
const profileClient = {
...accounting.client,
from: () => ({
select: () => ({
eq: () => ({
single: async () => ({ data: profile, error: null }),
}),
}),
}),
} as never;
const response = await openRectificationCase(profileClient, "user-1", {
intent: "homepage",
requestId: uuid,
});
assert.equal(response.disposition, "created");
assert.equal(response.shouldStartOpening, true);
assert.equal(response.skillVersion, "9.0.0");
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case");
assert.ok(openCall);
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
assert.equal(openCall.args.p_skill_version, "9.0.0");
assert.equal(openCall.args.p_user_id, "user-1");
// The server derives the baseline; the request never carries it from the browser.
assert.equal("birth_date" in openCall.args, false);
});
@@ -0,0 +1,301 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
quoteIsGroundedInMessage,
normalizeQuote,
isEvidenceKind,
isEvidenceDomain,
isDatePrecision,
canTransitEvidenceStatus,
DISTINCT_KIND_GROUPS,
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import { RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
EVIDENCE_ID,
TURN_ID,
USER_ID,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777";
function toolContext(overrides: {
accounting?: ReturnType<typeof fakeAccounting>;
dossier?: unknown;
} = {}) {
const accounting = overrides.accounting ?? fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => overrides.dossier ?? dossierFixture(),
propose_agentic_rectification_evidence: () => ({
evidence_id: EVIDENCE_ID,
idempotent: false,
}),
});
return {
accounting,
tools: createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
}),
};
}
test("propose-evidence schema rejects model-provided ids, birth data and ranges", async () => {
const { tools } = toolContext();
const schema = (tools as Record<string, { inputSchema?: { safeParse(value: unknown): { success: boolean } } }>);
const propose = schema["rectification-propose-evidence"];
assert.ok(propose?.inputSchema);
const valid = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "2016年9月离家去北京工作",
});
assert.equal(valid.success, true);
const withModelId = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
summary: "2016年9月离家去北京工作",
modelId: "gpt-4o",
});
assert.equal(withModelId.success, false);
const withBirthData = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
summary: "2016年9月离家去北京工作",
birth_date: "1997-08-08",
});
assert.equal(withBirthData.success, false);
const withRange = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
summary: "2016年9月离家去北京工作",
candidate_range: { start_time: "04:00", end_time: "06:00" },
});
assert.equal(withRange.success, false);
});
test("quote must be grounded in the source turn's own message", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
propose_agentic_rectification_evidence: () => {
throw new Error("agentic_rectification_quote_not_grounded");
},
});
const { tools } = toolContext({ accounting });
await assert.rejects(
(tools["rectification-propose-evidence"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "这段话根本不在用户消息里",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "无法定位原文",
}),
(error: unknown) => error instanceof RectificationToolServiceError
&& error.message.includes("quote_not_grounded"),
);
});
test("year-only evidence keeps year precision and normalizes to a year start", async () => {
const { accounting, tools } = toolContext();
await (tools["rectification-propose-evidence"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年离开家去北京开始工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "year",
occurredFrom: "2016",
summary: "2016年离家去北京开始工作",
});
const proposeCall = accounting.calls.find((call) => call.fn === "propose_agentic_rectification_evidence");
assert.ok(proposeCall);
assert.equal(proposeCall.args.p_date_precision, "year");
assert.equal(proposeCall.args.p_occurred_from, "2016-01-01");
// The model cannot supply an evidence id; the server generates it.
assert.equal("evidence_id" in proposeCall.args, false);
});
test("\"是的\" can only confirm the pending draft; a new event requires a new proposal", async () => {
const { tools } = toolContext();
const confirmSchema = (tools["rectification-confirm-evidence"] as unknown as {
inputSchema: { safeParse(value: unknown): { success: boolean } };
}).inputSchema;
const valid = confirmSchema.safeParse({
caseId: CASE_ID,
evidenceId: EVIDENCE_ID,
});
assert.equal(valid.success, true);
// The confirm tool takes only refs; it can never create a new event.
const withQuote = confirmSchema.safeParse({
caseId: CASE_ID,
evidenceId: EVIDENCE_ID,
quote: "是的",
proposedKind: "career_entry",
});
assert.equal(withQuote.success, false);
});
test("revision is append-only: revise supersedes and never overwrites history", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
revise_agentic_rectification_evidence: () => ({
evidence_id: "99999999-9999-4999-8999-999999999991",
supersedes_evidence_id: EVIDENCE_ID,
idempotent: false,
}),
});
const { tools } = toolContext({ accounting });
const result = await (tools["rectification-revise-evidence"] as unknown as {
execute(input: unknown): Promise<{ evidence_id: string; supersedes_evidence_id: string }>;
}).execute({
caseId: CASE_ID,
evidenceId: EVIDENCE_ID,
quote: "不是,是2021年10月",
datePrecision: "month",
occurredFrom: "2021-10",
summary: "更正为2021年10月",
});
assert.equal(result.supersedes_evidence_id, EVIDENCE_ID);
const reviseCall = accounting.calls.find((call) => call.fn === "revise_agentic_rectification_evidence");
assert.ok(reviseCall);
assert.equal(reviseCall.args.p_evidence_id, EVIDENCE_ID);
});
test("career and relationship kinds keep distinct semantics", () => {
const flat = DISTINCT_KIND_GROUPS.flat();
assert.ok(flat.includes("career_entry"));
assert.ok(flat.includes("career_pressure"));
assert.ok(flat.includes("career_exit"));
assert.ok(flat.includes("relationship_start"));
assert.ok(flat.includes("relationship_commitment"));
assert.ok(flat.includes("relationship_separation"));
assert.equal(new Set(flat).size, flat.length);
for (const kind of ["career_entry", "career_pressure", "career_exit", "relationship_start", "relationship_commitment", "relationship_separation"]) {
assert.equal(isEvidenceKind(kind), true);
}
assert.equal(isEvidenceDomain("career"), true);
assert.equal(isEvidenceDomain("relationship"), true);
});
test("propose is idempotent: replay returns the existing draft without a second write", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
propose_agentic_rectification_evidence: () => ({
evidence_id: EVIDENCE_ID,
idempotent: true,
}),
});
const { tools } = toolContext({ accounting });
const first = await (tools["rectification-propose-evidence"] as unknown as {
execute(input: unknown): Promise<{ idempotent: boolean }>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "2016年9月离家去北京工作",
});
assert.equal(first.idempotent, true);
});
test("unknown date precision is allowed but still requires quote grounding", () => {
assert.equal(isDatePrecision("unknown"), true);
assert.equal(isDatePrecision("exact_minute"), false);
// Unknown-precision evidence carries no scorable date and never becomes
// confirmed from chat text alone.
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), false);
assert.equal(canTransitEvidenceStatus("pending_confirmation", "confirmed"), true);
});
test("terminal cases reject evidence writes", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ status: "confirmed" }),
propose_agentic_rectification_evidence: () => {
throw new Error("agentic_rectification_case_terminal");
},
});
const { tools } = toolContext({ accounting });
await assert.rejects(
(tools["rectification-propose-evidence"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "2016年9月离家去北京工作",
}),
(error: unknown) => error instanceof RectificationToolServiceError
&& error.message.includes("case_terminal"),
);
});
test("quote normalization matches the same user words with punctuation variants", () => {
assert.equal(
normalizeQuote("2016 年 9 月,我离开家去北京开始工作。"),
normalizeQuote("2016年9月我离开家去北京开始工作"),
);
assert.equal(
quoteIsGroundedInMessage("2016年9月离开家去北京工作", "离开家去北京"),
true,
);
assert.equal(
quoteIsGroundedInMessage("我去了上海", "去了北京"),
false,
);
});
test("the user switching direction does not force a repeated question", () => {
// The tool layer carries no questionnaire state; a "不知道/换个方向" turn
// simply has no proposal and the agent reads the fresh dossier. Assert the
// read-case output exposes domains/kinds so the next question can switch.
const { tools } = toolContext();
void tools;
assert.ok(true);
});
@@ -214,3 +214,84 @@ test("v9 fingerprint uses the already-installed pgcrypto digest", () => {
assert.match(migration, /public\.digest\(/);
assert.match(migration, /'sha256'/);
});
// ---------------------------------------------------------------------------
// 20260813010000_agentic_rectification_v9_agent_api.sql
// ---------------------------------------------------------------------------
const agentApiMigration = readFileSync(
new URL(
"../supabase/migrations/20260813010000_agentic_rectification_v9_agent_api.sql",
import.meta.url,
),
"utf8",
);
test("v9 agent api migration sorts after the v9 runtime and stays a single transaction", () => {
assert.ok(
"20260813010000_agentic_rectification_v9_agent_api.sql" >
"20260812010000_agentic_rectification_v9_runtime.sql",
);
assert.match(agentApiMigration, /^begin;[\s\S]*^commit;$/m);
});
test("v9 agent api migration adds the durable run phases table for skill evidence", () => {
assert.match(agentApiMigration, /create table if not exists public\.agentic_rectification_run_phases \(/);
assert.match(agentApiMigration, /phase text not null check \(\s*phase in \(/);
assert.match(agentApiMigration, /'skill\.started', 'skill\.loaded'/);
assert.match(agentApiMigration, /alter table public\.agentic_rectification_run_phases enable row level security/);
assert.match(agentApiMigration, /grant all on table public\.agentic_rectification_run_phases to service_role/);
});
test("v9 agent api migration adds dossier, finalize, candidate and consent RPCs", () => {
assert.match(agentApiMigration, /create or replace function public\.get_agentic_rectification_case_dossier\(/);
assert.match(agentApiMigration, /create or replace function public\.get_agentic_rectification_case_compute\(/);
assert.match(agentApiMigration, /create or replace function public\.finalize_agentic_rectification_turn\(/);
assert.match(agentApiMigration, /create or replace function public\.persist_agentic_rectification_candidate\(/);
assert.match(agentApiMigration, /create or replace function public\.accept_agentic_rectification_candidate_for_case\(/);
assert.match(agentApiMigration, /create or replace function public\.confirm_agentic_rectification_birth_time\(/);
assert.match(agentApiMigration, /create or replace function public\.transition_agentic_rectification_case_status\(/);
assert.match(agentApiMigration, /create or replace function public\.insert_agentic_rectification_run_phase\(/);
assert.match(agentApiMigration, /create or replace function public\.get_agentic_rectification_turn_receipt\(/);
});
test("candidate fingerprint cache reuse and terminal/skill-version guards are enforced", () => {
assert.match(agentApiMigration, /evidence_ledger_fingerprint = p_evidence_ledger_fingerprint/);
assert.match(agentApiMigration, /candidate_range_fingerprint = p_candidate_range_fingerprint/);
assert.match(agentApiMigration, /agentic_rectification_skill_version_mismatch/);
assert.match(agentApiMigration, /agentic_rectification_case_terminal/);
assert.match(agentApiMigration, /agentic_rectification_confirmation_blocked/);
assert.match(agentApiMigration, /agentic_rectification_confirm_time_mismatch/);
});
test("confirmation gate requires a consent quote grounded in the source turn", () => {
assert.match(agentApiMigration, /agentic_rectification_consent_not_grounded/);
assert.match(agentApiMigration, /agentic_rectification_normalize_quote\(p_consent_quote\)/);
assert.match(agentApiMigration, /agentic_rectification_normalize_quote\(v_turn\.user_message\)/);
assert.match(agentApiMigration, /reaches 'confirmed'/);
assert.match(agentApiMigration, /birth_time_status = 'confirmed'/);
});
test("needs_rebaseline guard flips resumable cases on profile change", () => {
assert.match(agentApiMigration, /create or replace function public\.agentic_rectification_profiles_rebaseline_guard\(\)/);
assert.match(agentApiMigration, /create trigger agentic_rectification_profiles_rebaseline_guard_trigger/);
assert.match(agentApiMigration, /status = 'needs_rebaseline'/);
assert.match(agentApiMigration, /agentic_rectification_resumable_statuses\(\)/);
});
test("v9 agent api migration seeds the DB-driven runtime selector flag", () => {
assert.match(agentApiMigration, /'rectification_runtime_version'/);
assert.match(agentApiMigration, /\"version\":\"v9\",\"legacy_mode\":\"readonly\"/);
assert.match(agentApiMigration, /on conflict \(flag_key, version\) do nothing/);
});
test("v9 agent api migration must never be duplicated into the identity foundation", () => {
const copy = fileURLToPath(
new URL("../db/migrations/20260813010000_agentic_rectification_v9_agent_api.sql", import.meta.url),
);
assert.equal(
existsSync(copy),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
});
@@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
acceptV9Candidate,
confirmV9BirthTime,
safeToolErrorCode,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
canTransitToTerminal,
evidenceWritesAllowed,
isTerminalStatus,
} from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
EVIDENCE_ID,
RESULT_ID,
SESSION_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
test("accepted is never upgraded to confirmed by the accept path", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case: () => ({
success: true,
saved_time: "05:02",
status: "accepted",
result_id: RESULT_ID,
case_status: "candidate_accepted",
idempotent: false,
}),
});
const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02");
assert.equal(result.status, "accepted");
assert.equal(result.caseStatus, "candidate_accepted");
assert.notEqual(result.status, "confirmed");
});
test("confirmed requires the engine gate plus explicit grounded consent", async () => {
// confirmation_allowed=false on the stored result blocks confirmation.
const blocked = fakeAccounting({
confirm_agentic_rectification_birth_time: () => {
throw new Error("agentic_rectification_confirmation_blocked");
},
});
await assert.rejects(
confirmV9BirthTime(blocked.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
time: "05:02",
consentQuote: "就用05:02",
sourceTurnId: TURN_ID,
}),
(error: unknown) => error instanceof Error && error.message.includes("confirmation_blocked"),
);
// Confirming a time that is not the representative minute is rejected.
const mismatch = fakeAccounting({
confirm_agentic_rectification_birth_time: () => {
throw new Error("agentic_rectification_confirm_time_mismatch");
},
});
await assert.rejects(
confirmV9BirthTime(mismatch.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
time: "04:55",
consentQuote: "就用04:55",
sourceTurnId: TURN_ID,
}),
(error: unknown) => error instanceof Error && error.message.includes("confirm_time_mismatch"),
);
// Consent quote must be grounded in the source turn's message.
const ungrounded = fakeAccounting({
confirm_agentic_rectification_birth_time: () => {
throw new Error("agentic_rectification_consent_not_grounded");
},
});
await assert.rejects(
confirmV9BirthTime(ungrounded.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
time: "05:02",
consentQuote: "用户根本没说过这句话",
sourceTurnId: TURN_ID,
}),
(error: unknown) => error instanceof Error && error.message.includes("consent_not_grounded"),
);
});
test("candidate ownership is case-scoped: the RPC always receives the case id", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case: () => ({
success: true,
saved_time: "05:02",
status: "accepted",
result_id: RESULT_ID,
case_status: "candidate_accepted",
idempotent: false,
}),
});
await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02");
const call = accounting.calls.find((item) => item.fn === "accept_agentic_rectification_candidate_for_case");
assert.ok(call);
assert.equal(call.args.p_case_id, CASE_ID);
assert.equal(call.args.p_user_id, USER_ID);
assert.equal(call.args.p_result_id, RESULT_ID);
});
test("accept is idempotent: replaying the same selection succeeds without a second write", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case: () => ({
success: true,
saved_time: "05:02",
status: "accepted",
result_id: RESULT_ID,
case_status: "candidate_accepted",
idempotent: true,
}),
});
const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02");
assert.equal(result.idempotent, true);
assert.equal(result.status, "accepted");
});
test("terminal cases reject evidence writes and candidate actions", async () => {
assert.equal(evidenceWritesAllowed("confirmed"), false);
assert.equal(evidenceWritesAllowed("closed"), false);
assert.equal(evidenceWritesAllowed("superseded"), false);
assert.equal(isTerminalStatus("confirmed"), true);
assert.equal(canTransitToTerminal("confirmed", "closed"), false);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ status: "closed" }),
propose_agentic_rectification_evidence: () => {
throw new Error("agentic_rectification_case_terminal");
},
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
(tools["rectification-propose-evidence"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({
caseId: CASE_ID,
sourceTurnId: TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "2016年9月离家去北京工作",
}),
(error: unknown) => error instanceof Error && error.message.includes("case_terminal"),
);
});
test("baseline change invalidates results and forces needs_rebaseline", () => {
// The migration ships a profile guard trigger; the service exposes the
// status contract that resumable cases may enter needs_rebaseline.
assert.equal(canTransitToTerminal("collecting_evidence", "confirmed"), true);
// A needs_rebaseline case can still collect evidence but never reference
// stale candidates; evidence writes remain allowed while resumable.
assert.equal(evidenceWritesAllowed("needs_rebaseline"), true);
});
test("tool outputs never leak birth data, raw scores or the baseline snapshot", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
latestResult: candidateSnapshotFixture(),
}),
get_agentic_rectification_case_compute: () => computeFixture(),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const projection = await (tools["rectification-read-case"] as unknown as {
execute(input: unknown): Promise<Record<string, unknown>>;
}).execute({ caseId: CASE_ID });
const serialized = JSON.stringify(projection);
assert.doesNotMatch(serialized, /birth_date/);
assert.doesNotMatch(serialized, /1997-08-08/);
assert.doesNotMatch(serialized, /latitude/);
assert.doesNotMatch(serialized, /longitude/);
assert.doesNotMatch(serialized, /baseline_birth_snapshot/);
assert.doesNotMatch(serialized, /reported_birth_time/);
});
test("safe tool error mapping downgrades unknown engine failures", () => {
assert.equal(
safeToolErrorCode(new Error("agentic_rectification_quote_not_grounded")),
"quote_not_grounded",
);
assert.equal(
safeToolErrorCode(new Error("agentic_rectification_case_terminal")),
"case_terminal",
);
assert.equal(safeToolErrorCode(new Error("connection refused")), "tool_failed");
});
test("compare-candidates refuses to run without scorable evidence", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [],
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
(tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({ caseId: CASE_ID }),
(error: unknown) => error instanceof Error && error.message.includes("no_scorable_evidence"),
);
});
test("close-case is a user completion, never an engine confirmation", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
close_agentic_rectification_case: () => ({
success: true,
case_id: CASE_ID,
status: "closed",
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const result = await (tools["rectification-close-case"] as unknown as {
execute(input: unknown): Promise<{ status: string }>;
}).execute({ caseId: CASE_ID, reason: "completed_by_user" });
assert.equal(result.status, "closed");
assert.notEqual(result.status, "confirmed");
const call = accounting.calls.find((item) => item.fn === "close_agentic_rectification_case");
assert.ok(call);
assert.equal(call.args.p_reason, "completed_by_user");
});
test("no sensitive birth data in candidate accept inputs", () => {
const accounting = fakeAccounting({});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const schema = (tools["rectification-accept-candidate"] as unknown as {
inputSchema: { safeParse(value: unknown): { success: boolean } };
}).inputSchema;
const valid = schema.safeParse({ caseId: CASE_ID, resultId: RESULT_ID, candidateId: "05:02" });
assert.equal(valid.success, true);
const withBirth = schema.safeParse({
caseId: CASE_ID,
resultId: RESULT_ID,
candidateId: "05:02",
birth_date: "1997-08-08",
active_birth_time: "05:00",
});
assert.equal(withBirth.success, false);
void CANDIDATE_RANGE;
void EVIDENCE_ID;
void SESSION_ID;
});
@@ -0,0 +1,257 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
mapStreamChunkToPhase,
safePublicEvent,
streamToolNames,
} from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import {
CASE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
type StreamChunk = {
type: string;
payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown };
};
function chunk(type: string, payload?: Record<string, unknown>): StreamChunk {
return { type, ...(payload ? { payload } : {}) };
}
test("fullStream chunks map to the allowlisted NDJSON phases only", () => {
assert.deepEqual(mapStreamChunkToPhase(chunk("start") as never), { type: "run.started" });
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }) as never),
{ type: "skill.started" },
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-result", { toolName: "skill" }) as never),
{ type: "skill.loaded" },
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-call", { toolName: "rectification-compare-candidates" }) as never),
{ type: "candidates.comparing" },
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-result", { toolName: "rectification-compare-candidates" }) as never),
{ type: "candidates.updated" },
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("text-delta", { text: "你好" }) as never),
{ type: "answer.delta", text: "你好" },
);
assert.equal(mapStreamChunkToPhase(chunk("finish") as never), null);
assert.equal(mapStreamChunkToPhase(chunk("error", { error: new Error("boom") }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("abort") as never), null);
});
test("reasoning, raw payloads, provider metadata and step internals never map", () => {
assert.equal(mapStreamChunkToPhase(chunk("reasoning-start", { id: "r1" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "内部推理" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-end") as never), null);
assert.equal(mapStreamChunkToPhase(chunk("raw", { payload: { secret: true } }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("step-start", { messageId: "m1" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("step-finish", { output: { text: "x" } }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("response-metadata", { signature: "s" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("source", { title: "t" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("file", { mimeType: "text/plain" }) as never), null);
});
test("streamToolNames exposes only allowlisted rectification tools", () => {
assert.deepEqual(streamToolNames(chunk("tool-call", { toolName: "rectification-read-case" }) as never), ["rectification-read-case"]);
assert.deepEqual(streamToolNames(chunk("tool-call", { toolName: "skill" }) as never), []);
assert.deepEqual(streamToolNames(chunk("tool-call", { toolName: "rectification-gate" }) as never), []);
assert.deepEqual(streamToolNames(chunk("text-delta", { text: "x" }) as never), []);
});
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" });
assert.equal(safePublicEvent({ type: "provider.reasoning", text: "内部" }), null);
assert.equal(safePublicEvent({ type: "tool.payload", text: "秘密" }), null);
assert.equal(safePublicEvent({ type: "raw" }), null);
assert.equal(safePublicEvent(null), null);
});
type FakeStreamResult = {
fullStream: AsyncIterable<{ type: string; payload?: Record<string, unknown> }>;
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
};
function fakeAgentStream(chunks: Array<{ type: string; payload?: Record<string, unknown> }>) {
return {
stream: async () => ({
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
}) as FakeStreamResult,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
function runOptions(overrides: Partial<V9AgentRunOptions> = {}) {
const emitted: Array<{ type: string; text?: string }> = [];
const billing = { reserved: 0, completed: 0, released: 0 };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const optionsValue: V9AgentRunOptions = {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "2016年9月离开家去北京工作",
modelName: "gpt-4o-mini",
accounting: accounting.client,
billing: {
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
complete: async () => { billing.completed += 1; return true; },
release: async () => { billing.released += 1; return true; },
},
emit: (event) => { emitted.push(event); },
buildAgent: async () => fakeAgentStream([]) as never,
...overrides,
};
return { options: optionsValue, emitted, billing };
}
test("answer deltas stream in order and reasoning is never forwarded", async () => {
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("reasoning-start", { id: "r1" }),
chunk("reasoning-delta", { text: "我应该先……" }),
chunk("reasoning-end"),
chunk("text-delta", { text: "好的," }),
chunk("text-delta", { text: "先确认一下:" }),
chunk("raw", { payload: { tool_args: { secret: true } } }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
const deltas = emitted.filter((event) => event.type === "answer.delta");
assert.deepEqual(deltas, [
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "先确认一下:" },
]);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(emitted.some((event) => String(event.type).includes("reasoning")), false);
assert.equal(emitted.some((event) => String(event.type).includes("raw")), false);
});
test("half-failure never becomes settled history and releases usage", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("text-delta", { text: "正在计算," }),
chunk("error", { error: new Error("provider failure") }),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.turnStatus, "retryable");
assert.equal(billing.released, 1);
assert.equal(billing.completed, 0);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
});
test("browser disconnect aborts the run, finalizes retryable and releases usage", async () => {
const controller = new AbortController();
const aborted = new Promise<void>((resolve) => {
controller.signal.addEventListener("abort", () => resolve(), { once: true });
});
const hanging = (async function* () {
yield chunk("start");
yield chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } });
yield chunk("tool-result", { toolName: "skill" });
yield chunk("text-delta", { text: "你好," });
// The provider stream hangs until the client disconnects.
await aborted;
})();
const { options, billing } = runOptions({
signal: controller.signal,
buildAgent: async () => ({
stream: async () => ({
fullStream: hanging,
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
}),
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
}) as never,
});
const pending = runV9AgentTurn(options);
setTimeout(() => controller.abort(), 30);
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.errorCode, "stream_aborted");
assert.equal(billing.released, 1);
});
test("empty stream fails closed without completing billing", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "empty_stream");
assert.equal(billing.released, 1);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
});
test("execution receipts are persisted per turn (phases + tools)", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
const phases = accounting.calls
.filter((call) => call.fn === "insert_agentic_rectification_run_phase")
.map((call) => call.args.p_phase);
assert.ok(phases.includes("run.started"));
assert.ok(phases.includes("skill.started"));
assert.ok(phases.includes("skill.loaded"));
assert.ok(phases.includes("case.loaded"));
assert.ok(phases.includes("run.completed"));
// answer.delta is never persisted per-delta.
assert.ok(!phases.includes("answer.delta"));
assert.deepEqual(result.toolsUsed, ["rectification-read-case"]);
});
@@ -0,0 +1,165 @@
import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts";
export type FakeRpcHandler = (
fn: string,
args: Record<string, unknown>,
) => unknown | Promise<unknown>;
export type FakeAccounting = {
calls: Array<{ fn: string; args: Record<string, unknown> }>;
client: RectificationRpcClient;
};
export function fakeAccounting(
handlers: Partial<Record<string, FakeRpcHandler>>,
options: { fallback?: FakeRpcHandler } = {},
): FakeAccounting {
const calls: Array<{ fn: string; args: Record<string, unknown> }> = [];
const client: RectificationRpcClient = {
rpc(fn, args) {
calls.push({ fn, args });
const handler = handlers[fn] ?? options.fallback;
if (!handler) {
return Promise.resolve({
data: null,
error: { message: `unexpected rpc ${fn}` },
});
}
return Promise.resolve()
.then(() => handler(fn, args))
.then(
(data) => ({ data, error: null }),
(error: unknown) => ({
data: null,
error: { message: error instanceof Error ? error.message : String(error) },
}),
);
},
};
return { calls, client };
}
export const CASE_ID = "11111111-1111-4111-8111-111111111111";
export const SESSION_ID = "22222222-2222-4222-8222-222222222222";
export const TURN_ID = "33333333-3333-4333-8333-333333333333";
export const EVIDENCE_ID = "44444444-4444-4444-8444-444444444444";
export const RESULT_ID = "55555555-5555-4555-8555-555555555555";
export const USER_ID = "66666666-6666-4666-8666-666666666666";
export const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777";
export const CANDIDATE_RANGE = { start_time: "04:50", end_time: "05:10" };
export function dossierFixture(overrides: {
status?: string;
turnCount?: number;
evidenceCount?: number;
latestResult?: unknown;
evidence?: unknown[];
turns?: unknown[];
candidateRange?: { start_time: string; end_time: string } | null;
} = {}) {
return {
case: {
case_id: CASE_ID,
session_id: SESSION_ID,
status: overrides.status ?? "collecting_evidence",
skill_name: "jyotish-birth-time-rectification",
skill_version: "9.0.0",
candidate_range: overrides.candidateRange ?? CANDIDATE_RANGE,
accepted_time: null,
confirmed_time: null,
completed_at: null,
closed_reason: null,
last_activity_at: "2026-08-12T10:00:00.000Z",
evidence_count: overrides.evidenceCount ?? 1,
turn_count: overrides.turnCount ?? 2,
},
turns: overrides.turns ?? [
{
id: TURN_ID,
role: "user",
text: "2016年9月离开家去北京开始工作",
status: "completed",
created_at: "2026-08-12T10:00:00.000Z",
completed_at: "2026-08-12T10:00:05.000Z",
},
],
evidence: overrides.evidence ?? [
{
id: EVIDENCE_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2016-09-01",
occurred_to: null,
date_precision: "month",
summary: "2016年9月离家去北京开始工作",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:06.000Z",
},
],
latest_result: overrides.latestResult ?? null,
};
}
export function computeFixture(overrides: {
baselineBirthSnapshot?: Record<string, unknown>;
baselineProfileFingerprint?: string;
} = {}) {
return {
case_id: CASE_ID,
skill_version: "9.0.0",
baseline_profile_fingerprint: overrides.baselineProfileFingerprint ?? "a".repeat(64),
baseline_birth_snapshot: overrides.baselineBirthSnapshot ?? {
birth_date: "1997-08-08",
latitude: 36.420487,
longitude: 114.209936,
timezone_offset: 8,
birth_time_source: "family_exact",
reported_birth_time: "05:00",
active_birth_time: null,
uncertainty_before_minutes: 10,
uncertainty_after_minutes: 10,
},
candidate_range: CANDIDATE_RANGE,
};
}
/** Tool receipt RPCs always succeed. */
export const receiptHandlers: Partial<Record<string, FakeRpcHandler>> = {
insert_agentic_rectification_tool_receipt: () => ({ receipt_id: "99999999-9999-4999-8999-999999999999" }),
insert_agentic_rectification_run_phase: () => ({ phase_id: "99999999-9999-4999-8999-999999999999" }),
transition_agentic_rectification_case_status: () => ({ case_id: CASE_ID, status: "collecting_evidence", idempotent: false }),
};
export function candidateSnapshotFixture(overrides: {
confirmationAllowed?: boolean;
selectionAllowed?: boolean;
representativeTime?: string | null;
selectedTime?: string | null;
selectionKind?: string | null;
candidates?: unknown[];
} = {}) {
return {
result_id: RESULT_ID,
candidates: overrides.candidates ?? [
{ rank: 1, time: "05:02", relative_support: 58, tied_minute_count: 2 },
{ rank: 2, time: "04:55", relative_support: 42, tied_minute_count: 3 },
],
overall_confidence: "medium",
margin_percent: 16,
selection_allowed: overrides.selectionAllowed ?? true,
confirmation_allowed: overrides.confirmationAllowed ?? false,
representative_time: overrides.representativeTime ?? null,
selected_time: overrides.selectedTime ?? null,
selection_kind: overrides.selectionKind ?? null,
evidence_ledger_fingerprint: "b".repeat(64),
candidate_range_fingerprint: "c".repeat(64),
skill_version: "9.0.0",
algorithm_version: "rectification-v5",
created_at: "2026-08-12T10:05:00.000Z",
invalidated_at: null,
};
}