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