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:
@@ -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/);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user