91b8b33aa6
Opening a Case now hydrates turns and snapshot in one read (4s budget) before the session switches, so the sidebar no longer flashes a plain transcript, the panel never mounts empty, and the first completed turn no longer remounts the whole surface (the key is the session/Case binding only). Entry feedback is static: the card says 正在打开…, the sidebar row 打开中. A tapped choice or an adopted candidate continues the follow-up turn on the live row already in place, with busy held across the chain, so there is no empty frame and no next card flashing in. The question slot has four pure states — a gap is a timeline live row with timed refetches, then a reload — and no copy asks the reader to wait for the server. A resumed Case with no turns shows 这段校正还没有开始 and 开始提问. Stopping keeps what streamed and says so; 402 explains before redirecting; the opening row names what it is doing; the tap is echoed as the reader's own line. BUG-479, BUG-480, BUG-481, BUG-482 (echo) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
777 lines
43 KiB
TypeScript
777 lines
43 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { homeSurface as page } from "./home-surface.ts";
|
|
|
|
const component = readFileSync(
|
|
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const chat = readFileSync(
|
|
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const activityStatus = readFileSync(
|
|
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const messageActions = readFileSync(
|
|
new URL("../src/components/chat-message-actions.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const route = readFileSync(
|
|
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const agent = readFileSync(
|
|
new URL("../src/mastra/agentic-rectification.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
|
const board = readFileSync(
|
|
new URL("../src/components/rectification-board.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const boardModel = readFileSync(
|
|
new URL("../src/lib/rectification-board-model.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const houseTable = readFileSync(
|
|
new URL("../src/components/rectification-house-table.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const progressLabels = readFileSync(
|
|
new URL("../src/lib/rectification-activity-labels.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const choiceCardComponent = readFileSync(
|
|
new URL("../src/components/rectification-choice-card.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const caseRoute = readFileSync(
|
|
new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const adoptSkillRoute = readFileSync(
|
|
new URL("../src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const upgradeSkillRoute = readFileSync(
|
|
new URL("../src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const regenerateRoute = readFileSync(
|
|
new URL(
|
|
"../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts",
|
|
import.meta.url,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
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("persisted rectification turns hydrate after the async Case refresh", () => {
|
|
assert.match(chat, /function messagesFromTurns\(initialTurns:/);
|
|
assert.match(chat, /useState<RenderMessage\[\]>\(\(\) => messagesFromTurns\(initialTurns\)\)/);
|
|
assert.doesNotMatch(chat, /spoken-answer/);
|
|
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
|
|
assert.match(chat, /text: raw,/);
|
|
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
|
|
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
|
|
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
|
|
// prop update; the key is the session/Case binding only.
|
|
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
|
assert.doesNotMatch(page, /"ready" : "loading"/);
|
|
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
|
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
|
|
// Former lock read this parser out of the page surface (`use-rectification-surface.ts`). The
|
|
// turn parser moved to `rectification-surface-state.ts` so the hook's hydration and the chat's
|
|
// late-snapshot fill share one (BUG-479); the shape it produces is unchanged.
|
|
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
|
|
assert.match(surfaceState, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
|
assert.match(page, /parsePersistedRectificationTurns\(payload\?\.turns\)/);
|
|
assert.match(component, /methods\?: readonly string\[\]/);
|
|
});
|
|
|
|
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, /shouldStartOpening/);
|
|
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
|
|
assert.match(chat, /if \(readonly \|\| openingStarted\.current \|\| !shouldStartOpening\) return/);
|
|
assert.match(chat, /onOpeningConsumed\?\.\(\)/);
|
|
assert.match(chat, /void send\("opening", ""\)/);
|
|
assert.match(route, /import \{ createServerSupabaseClient \} from "@\/lib\/supabase\/server"/);
|
|
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review"\]\)/);
|
|
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
|
|
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
|
|
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
|
});
|
|
|
|
test("incomplete profiles stay in the shared onboarding flow before any open request", () => {
|
|
const opening = page.slice(
|
|
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("/api/rectification/cases/open"),
|
|
);
|
|
assert.match(chat, /payload\?\.code === "profile_incomplete"/);
|
|
});
|
|
|
|
test("server profile failures pause automatic rectification resume", () => {
|
|
const resumeEffect = page.slice(
|
|
page.indexOf('useEffect(() => {\n if (!hydrated'),
|
|
page.indexOf('useEffect(() => {\n if (!hydrated || !accountId'),
|
|
);
|
|
const incompleteHandler = page.slice(
|
|
page.indexOf("function handleRectificationProfileIncomplete"),
|
|
page.indexOf("function handleRectificationMessagesChange"),
|
|
);
|
|
assert.match(resumeEffect, /\|\| rectificationError\) return/);
|
|
assert.match(incompleteHandler, /setRectificationError\("profile_incomplete"\)/);
|
|
assert.ok(
|
|
incompleteHandler.indexOf('setRectificationError("profile_incomplete")')
|
|
< incompleteHandler.indexOf("setRectificationSessionId(null)"),
|
|
);
|
|
});
|
|
|
|
test("homepage rectification open failures always surface a visible recovery message", () => {
|
|
const opening = page.slice(
|
|
page.indexOf("async function openRectificationCase"),
|
|
page.indexOf("async function openRectificationFromHomepage"),
|
|
);
|
|
|
|
assert.match(
|
|
opening,
|
|
/code === "profile_incomplete"[\s\S]*?handleRectificationProfileIncomplete\(\)/,
|
|
);
|
|
assert.match(
|
|
opening,
|
|
/code === "invalid_open_request"[\s\S]*?setRectificationError\(/,
|
|
);
|
|
assert.match(
|
|
opening,
|
|
/catch \{[\s\S]*?setRectificationError\("生时校正会话暂时无法打开,请稍后重试。"\)/,
|
|
);
|
|
assert.match(
|
|
page,
|
|
/rectificationError && !rectificationSurfaceOpen && \([\s\S]*?role="alert"[\s\S]*?rectificationErrorMessage/,
|
|
);
|
|
});
|
|
|
|
test("account rehydration normalizes persisted ISO birth dates before completeness checks", () => {
|
|
const profileReader = page.slice(
|
|
page.indexOf("function readProfile"),
|
|
page.indexOf("function readSessions"),
|
|
);
|
|
assert.match(profileReader, /const date = normalizePersistedBirthDate\(/);
|
|
});
|
|
|
|
test("candidate acceptance refreshes the profile result without overwriting an open draft", () => {
|
|
const refresh = page.slice(
|
|
page.indexOf("async function refreshAccount"),
|
|
page.indexOf("function openAccountDialog"),
|
|
);
|
|
assert.match(refresh, /const nextProfile = readProfile\(latest\.profile\)/);
|
|
assert.match(
|
|
refresh,
|
|
/setProfile\(\(current\) => preserveShallowEqual\(current, nextProfile\)\)/,
|
|
);
|
|
assert.doesNotMatch(refresh, /setProfileDraft/);
|
|
});
|
|
|
|
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("persisted turns survive remounts; duplicate openings are suppressed by the server", () => {
|
|
assert.match(chat, /initialTurns/);
|
|
assert.match(chat, /const openingStarted = useRef\(false\)/);
|
|
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
|
|
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
|
|
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
|
|
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
|
|
// prop update; the key is the session/Case binding only.
|
|
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
|
assert.doesNotMatch(page, /"ready" : "loading"/);
|
|
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
|
assert.match(page, /initialTurns=\{rectificationTurns\}/);
|
|
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
|
|
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
|
});
|
|
|
|
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, /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 acceptance is non-billable, mutually exclusive, and continues through the durable case endpoint", () => {
|
|
assert.match(chat, /\/candidates\/accept/);
|
|
assert.match(chat, /resultId: candidateResult\.resultId/);
|
|
assert.match(chat, /if \(!candidateResult \|\| acceptingCandidateId \|\| busy \|\| readonly\) return;/);
|
|
assert.match(chat, /setAcceptingCandidateId\(candidateId\);[\s\S]*setPending\(true\);/);
|
|
// Former locks: `await loadCaseSnapshot(); … choiceContinuationPending.current = true;`, the
|
|
// `if (!choiceContinuationPending.current || busy || readonly) return; … void send("read_only", "")`
|
|
// effect, and a doesNotMatch on `await loadCaseSnapshot(); … await send("read_only", "")`. That
|
|
// effect hop dropped the live row for a frame and let the next card flash in (BUG-480); the
|
|
// follow-up turn now continues in the same async chain on the same row, still through the
|
|
// durable case endpoint first.
|
|
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
|
|
assert.match(chat, /finally \{[\s\S]*setAcceptingCandidateId\(null\);[\s\S]*setPending\(false\);/);
|
|
assert.doesNotMatch(chat, /choiceContinuationPending/);
|
|
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
|
|
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(acceptRoute, /accept_agentic_rectification_candidate_for_case/);
|
|
assert.doesNotMatch(acceptRoute, /authorizeUsage|completeUsage|begin_consultation_credit/);
|
|
});
|
|
|
|
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(run, /thinking: "enabled"/);
|
|
assert.match(run, /answerTokens: 8_192/);
|
|
assert.match(run, /thinkingTokens: 8_192/);
|
|
assert.match(run, /activity.changed/);
|
|
assert.match(run, /if \(!answerText\.trim\(\)\) return failedAttempt\(attemptId, "empty_stream"\)/);
|
|
assert.doesNotMatch(run, /bindSpokenToOpenQuestion|CHOICE_CARD_CONTINUATION_ACK|openQuestionPromptFromToolResult/);
|
|
assert.doesNotMatch(run, /heldSpoken/);
|
|
assert.match(run, /replace: true/);
|
|
const narration = readFileSync(
|
|
new URL("../src/lib/rectification-agentic/v9/turn-narration.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(narration, /payload\?: unknown/);
|
|
assert.doesNotMatch(run, /splitRectificationSpokenAndThinking/);
|
|
assert.match(run, /emit\(event: PublicStreamEvent\): Promise<void> \| void;/);
|
|
assert.match(run, /agentGenerationSettings\(options\.generationModel, \{[\s\S]*thinking: "enabled"/);
|
|
assert.match(route, /featureKey: "rectification"/);
|
|
assert.match(route, /rectification:case:\$\{caseId\}/);
|
|
});
|
|
|
|
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("rectification uses one case-level entitlement and the session-pinned model version", () => {
|
|
// Former value: select included unused `messages` and pulled up to 500KB per turn.
|
|
assert.match(route, /select\("id,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:case:\$\{caseId\}`/);
|
|
assert.match(route, /requestId: billingRequestId/);
|
|
assert.match(route, /modelConfigVersion: selectedModel\.configVersion/);
|
|
assert.match(route, /generationModel: selectedModel\.model/);
|
|
assert.doesNotMatch(route, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
|
|
});
|
|
|
|
test("Agentic rectification follows the conversation tail only while the reader stays near the bottom", () => {
|
|
// Former locks pinned the rectification-only follow (`followTailRef`, `isNearBottom`,
|
|
// `shouldShowJumpToLatest`, `choiceCardsOpen`, `followLatestContent`, the 「回到最新」 chip and
|
|
// `.rectification-jump-latest`). That was the second scroll implementation beside the
|
|
// consultation hook (BUG-478); both surfaces now share `useConversationScrollAnchor` and
|
|
// `JumpToLatestButton`. The choice-card band is gone with it: the card sits inside the
|
|
// transcript, so a reader looking at it is near the bottom and anchored.
|
|
assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/);
|
|
assert.match(chat, /<section[\s\S]*ref=\{conversation\}[\s\S]*className="conversation is-rectification"/);
|
|
assert.match(chat, /const conversationAnchor = useConversationScrollAnchor\(conversation, true, caseId\)/);
|
|
assert.match(chat, /\{!conversationAnchor\.anchored && \(\s*<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
|
assert.doesNotMatch(chat, /followTailRef|isNearBottom|shouldShowJumpToLatest|choiceCardsOpen|followLatestContent|updateFollowState|回到最新|onScroll=/);
|
|
assert.doesNotMatch(styles, /\.rectification-jump-latest/);
|
|
assert.match(styles, /\.jump-to-latest \{[^}]*justify-content: center/);
|
|
assert.match(styles, /--rectification-jump-clearance/);
|
|
assert.match(styles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
|
|
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
|
|
assert.doesNotMatch(styles, /scrollIntoView/);
|
|
});
|
|
|
|
test("rectification keeps receipts for the varga sentence and shows live tool progress", () => {
|
|
const activityHelper = chat.slice(
|
|
chat.indexOf("function completedReceiptFromPersisted"),
|
|
chat.indexOf("export function RectificationAgenticChat"),
|
|
);
|
|
assert.match(activityHelper, /receipt\.tools/);
|
|
assert.doesNotMatch(activityHelper, /receipt\.phases/);
|
|
assert.match(activityHelper, /filter\(isPublicRectificationTool\)/);
|
|
assert.match(activityHelper, /receipt\.methods/);
|
|
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
|
|
assert.match(chat, /completedReceipt\?: CompletedActivityReceiptView/);
|
|
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
|
|
assert.match(chat, /vargaSentenceFromMethods/);
|
|
assert.match(chat, /vargaSentence=\{vargaSentence\}/);
|
|
assert.match(chat, /activityTraceFromReceipt/);
|
|
assert.match(activityStatus, /vargaSentence/);
|
|
assert.match(activityStatus, /VargaTraceStep/);
|
|
assert.match(board, /RectificationHouseTableView/);
|
|
assert.doesNotMatch(chat, /activeActivity/);
|
|
assert.match(chat, /RECTIFICATION_TOOL_PROGRESS_LABELS/);
|
|
assert.match(chat, /rectificationCompletedTrail\(activityReceiptState\.completedSteps\)/);
|
|
assert.match(progressLabels, /正在读取校正记录/);
|
|
assert.match(progressLabels, /正在比较候选时间/);
|
|
assert.doesNotMatch(chat, /<CompletedActivityReceipt/);
|
|
assert.match(chat, /event\.type === "tool\.activity"/);
|
|
assert.match(chat, /event\.status === "started"/);
|
|
assert.match(chat, /event\.status === "completed"/);
|
|
assert.match(chat, /event\.status !== "completed" && event\.status !== "failed"/);
|
|
|
|
const messageRender = chat.slice(
|
|
chat.indexOf("{messages.map((message) => {"),
|
|
chat.indexOf("{savedTime &&"),
|
|
);
|
|
const replyIndex = messageRender.indexOf("<ChatMessageRow");
|
|
assert.ok(replyIndex >= 0);
|
|
assert.doesNotMatch(messageRender, /<RectificationHouseTableView/);
|
|
// Former locks on `completed-activity-receipt.tsx` (`<details>`, never open, the "本轮完成 · N 个
|
|
// 步骤" summary): that was a second collapsed block under every settled rectification reply.
|
|
// Its methods now sit as source chips on the shared timeline's calculate row (BUG-476).
|
|
assert.match(chat, /completedReceipt/);
|
|
for (const genericCopy of ["开始本轮执行", "正在加载专用方法", "专用方法已加载", "本轮做了什么"]) {
|
|
assert.doesNotMatch(chat, new RegExp(genericCopy));
|
|
}
|
|
assert.match(chat, /回答未完成,已保留现有内容;本次不会扣点/);
|
|
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
|
|
assert.doesNotMatch(chat, /event.type === "thinking.delta"/);
|
|
assert.doesNotMatch(chat, /thinkingText: settled\.thinking/);
|
|
assert.doesNotMatch(chat, /\.\.\.\(thinkingText \? \{ thinkingText \}/);
|
|
assert.match(chat, /event.type === "activity.changed"/);
|
|
assert.match(chat, /userFacingRunFailure/);
|
|
assert.doesNotMatch(chat, /本轮处理未完成,已保留服务端记录的执行进度/);
|
|
assert.match(chat, /startActivityTraceStep/);
|
|
assert.match(chat, /completeActivityTraceStep/);
|
|
// Former lock: `activityStatus` contained `activityTrace` — the rectification-only trace panel.
|
|
// The trace is now projected onto the shared timeline rows (BUG-476); the panel is a fallback.
|
|
assert.match(chat, /rectificationTimelineRows/);
|
|
assert.doesNotMatch(activityStatus, /activityTrace/);
|
|
assert.match(activityStatus, /className="message-thinking"/);
|
|
assert.match(activityStatus, />思考</);
|
|
assert.match(activityStatus, /userOpen \?\? !hasAnswer/);
|
|
assert.match(styles, /\.message-thinking-body/);
|
|
});
|
|
|
|
test("completed Agent replies restore feedback, copy and safe in-place regeneration actions", () => {
|
|
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
|
|
assert.match(messageActions, new RegExp(`aria-label="${label}"`));
|
|
}
|
|
assert.match(chat, /<ChatMessageActions/);
|
|
assert.match(chat, /toggleRectificationFeedback/);
|
|
assert.match(chat, /navigator\.clipboard\.writeText\(message\.text\)/);
|
|
assert.match(chat, /\/turns\/\$\{encodeURIComponent\(message\.turnId\)\}\/regenerate/);
|
|
assert.match(chat, /requestId: globalThis\.crypto\.randomUUID\(\)/);
|
|
|
|
const messageRender = chat.slice(
|
|
chat.indexOf("{messages.map((message) => {"),
|
|
chat.indexOf("{savedTime &&"),
|
|
);
|
|
const replyIndex = messageRender.indexOf("<ChatMessageRow");
|
|
const actionsIndex = messageRender.indexOf("<ChatMessageActions");
|
|
assert.ok(replyIndex >= 0 && actionsIndex >= 0);
|
|
assert.ok(replyIndex < actionsIndex);
|
|
assert.doesNotMatch(messageRender, /<CompletedActivityReceipt/);
|
|
|
|
assert.doesNotMatch(chat, /send\("message",\s*(?:oldText|previousText)/);
|
|
assert.doesNotMatch(regenerateRoute, /consultation-billing|reserveConsultation|billing\./);
|
|
assert.match(regenerateRoute, /loadV9CaseSkillIdentity\(accounting, user\.id, caseId\)/);
|
|
assert.match(regenerateRoute, /resolveExactSkillPackage\(/);
|
|
assert.match(regenerateRoute, /skillPackage\.sourceCommit !== boundIdentity\.sourceCommit/);
|
|
assert.match(regenerateRoute, /getRectificationV9RegenerationAgent\([\s\S]*skillPackage\)/);
|
|
assert.match(regenerateRoute, /regenerateV9AssistantTurn/);
|
|
assert.match(regenerateRoute, /skillPackage,/);
|
|
});
|
|
|
|
test("candidate state renders from the snapshot API and never from sentinels", () => {
|
|
assert.match(chat, /当前可能的出生时间/);
|
|
assert.doesNotMatch(chat, /本会话以代表性时间收口,不确认唯一分钟/);
|
|
assert.doesNotMatch(chat, /当前排盘时间(代表性时间,本会话不确认唯一分钟)/);
|
|
assert.match(chat, /相对支持度不是统计概率/);
|
|
assert.match(chat, /改选为此时间/);
|
|
assert.doesNotMatch(chat, /disabled=\{Boolean\(candidateResult\.selectedTime\)/);
|
|
assert.match(styles, /\.rectification-candidate-list \{[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/);
|
|
assert.match(styles, /\.rectification-candidate-list \{[\s\S]*min-width: 0/);
|
|
assert.doesNotMatch(chat, /relativeSupport}\%/);
|
|
assert.match(chat, /相对支持度 {candidate.relativeSupport}/);
|
|
assert.match(chat, /isRecommendedRectificationCandidate/);
|
|
assert.match(chat, /已采用/);
|
|
assert.match(chat, /正在采用…/);
|
|
assert.match(chat, /RectificationBoard/);
|
|
assert.match(board, /className="rectification-snapshot"/);
|
|
assert.match(styles, /\.rectification-snapshot \{/);
|
|
assert.match(styles, /\.rectification-house-table \{[^}]*border:\s*1px solid var\(--color-border\)/);
|
|
assert.match(styles, /\.rectification-house-table \{[^}]*background:\s*var\(--color-canvas-soft\)/);
|
|
assert.match(styles, /\.message-list \{[\s\S]*--assistant-content-inset: calc\(32px \+ var\(--space-3\)\)/);
|
|
assert.match(styles, /\.rectification-message-wrap \.rectification-candidates \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
|
|
assert.match(styles, /\.rectification-message-wrap \.rectification-candidates \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
|
|
assert.match(styles, /\.rectification-workspace \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(18rem, 22\.5rem\)/);
|
|
assert.match(styles, /\.rectification-workspace\.is-compact \{[\s\S]*grid-template-columns: minmax\(0, 1fr\)/);
|
|
assert.match(chat, /matchMedia\(`\(max-width: \$\{RECTIFICATION_BOARD_SPLIT_MIN_PX - 1\}px\)`\)/);
|
|
assert.match(chat, /nextCompact = query\.matches/);
|
|
assert.doesNotMatch(chat, /clientWidth < RECTIFICATION_BOARD_SPLIT_MIN_PX/);
|
|
assert.match(boardModel, /RECTIFICATION_BOARD_SPLIT_MIN_PX = 768/);
|
|
assert.match(board, /if \(!compact\) return sheet;/);
|
|
assert.doesNotMatch(styles, /\.rectification-snapshot \{[^}]*--assistant-content-inset/);
|
|
assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*padding-inline-start:\s*var\(--assistant-content-inset\)/);
|
|
assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*--assistant-content-inset/);
|
|
assert.doesNotMatch(page, /rectification-workspace/);
|
|
assert.match(page, /chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}/);
|
|
});
|
|
|
|
|
|
test("the natal house table is a live board beside the chat, not a message", () => {
|
|
const messageLoop = chat.slice(
|
|
chat.indexOf("{messages.map((message) => {"),
|
|
chat.indexOf("{savedTime &&"),
|
|
);
|
|
const snapshot = board.slice(
|
|
board.indexOf("className=\"rectification-snapshot\""),
|
|
board.indexOf("{scoringMinutes.length > 0 || displayMinutes.length > 0"),
|
|
);
|
|
const rowIndex = messageLoop.indexOf("<ChatMessageRow");
|
|
const snapshotIndex = snapshot.indexOf("className=\"rectification-snapshot\"");
|
|
const houseIndex = snapshot.indexOf("<RectificationHouseTableView");
|
|
assert.ok(rowIndex >= 0);
|
|
assert.ok(snapshotIndex >= 0 && houseIndex > snapshotIndex);
|
|
assert.doesNotMatch(snapshot, /<RectificationCandidateCards/);
|
|
assert.doesNotMatch(snapshot, /当前可能的出生时间/);
|
|
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
|
|
assert.doesNotMatch(messageLoop, /<RectificationHouseTableView/);
|
|
assert.match(chat, /className="conversation is-rectification"/);
|
|
assert.match(chat, /<RectificationBoard/);
|
|
assert.match(chat, /await loadCaseSnapshot\(\)/);
|
|
assert.doesNotMatch(chat, /void loadCaseSnapshot\(\)/);
|
|
assert.match(chat, /signal: controller\.signal/);
|
|
assert.match(board, /aria-live="polite"/);
|
|
assert.match(board, /groupWindowTransitions/);
|
|
assert.match(board, /<aside/);
|
|
assert.doesNotMatch(board, /<dialog/);
|
|
assert.doesNotMatch(board, /showModal/);
|
|
assert.doesNotMatch(board, /onCloseRef\.current = onClose/);
|
|
assert.match(board, /event\.key === "Escape"/);
|
|
assert.match(board, /aria-label="关闭盘面"/);
|
|
assert.match(board, /className="rectification-board__handle"/);
|
|
assert.match(board, /className="rectification-board-overlay"/);
|
|
assert.match(board, /className="rectification-board-scrim"/);
|
|
assert.match(board, /role=\{compact \? "dialog" : undefined\}/);
|
|
assert.match(board, /aria-modal=\{compact \? true : undefined\}/);
|
|
assert.match(chat, /RectificationBoardPeek/);
|
|
assert.match(chat, /onOpen=\{toggleBoard\}/);
|
|
assert.match(chat, /inert=\{compactBoard && boardOpen \? true : undefined\}/);
|
|
assert.match(houseTable, /当前本命宫位/);
|
|
assert.match(houseTable, /补充经历后会按新线索重算/);
|
|
assert.doesNotMatch(houseTable, /<ChatMessageRow/);
|
|
});
|
|
|
|
test("compact board overlays chat as a bottom sheet above the composer", () => {
|
|
assert.doesNotMatch(styles, /\.rectification-workspace\.is-compact\.is-board-open \{[\s\S]*grid-template-rows: minmax\(8rem, 1fr\) minmax\(13rem, 46%\)/);
|
|
assert.match(styles, /\.rectification-workspace \{[\s\S]*isolation: isolate/);
|
|
assert.match(styles, /\.rectification-workspace \{[\s\S]*z-index: 0/);
|
|
assert.match(styles, /\.rectification-workspace\.is-compact \.rectification-board-overlay \{[\s\S]*position: absolute/);
|
|
assert.match(styles, /\.rectification-workspace\.is-compact \.rectification-board-overlay \{[\s\S]*inset: 0/);
|
|
assert.match(styles, /\.rectification-workspace\.is-compact \.rectification-board-overlay \{[^}]*z-index: 50/);
|
|
assert.doesNotMatch(styles, /\.rectification-workspace\.is-compact \.rectification-board-overlay \{[^}]*z-index: 20/);
|
|
assert.match(styles, /\.rectification-workspace\.is-compact\.is-board-open \.rectification-workspace__chat \.composer-wrap \{[\s\S]*visibility: hidden/);
|
|
assert.match(styles, /\.rectification-workspace\.is-compact\.is-board-open \.rectification-workspace__chat \.composer-wrap \{[\s\S]*backdrop-filter: none/);
|
|
assert.match(styles, /\.rectification-workspace__chat \.composer-wrap \{[\s\S]*z-index: 2/);
|
|
assert.match(styles, /\.rectification-board\.is-sheet \{[^}]*height: 72%/);
|
|
assert.doesNotMatch(styles, /\.rectification-board\.is-sheet \{[^}]*height: 92%/);
|
|
assert.match(styles, /\.rectification-board\.is-sheet \{[\s\S]*background: var\(--color-canvas\)/);
|
|
assert.match(styles, /\.rectification-board\.is-sheet \{[\s\S]*border-block-start: 1px solid var\(--color-border\)/);
|
|
assert.match(styles, /@keyframes rectification-sheet-enter \{[\s\S]*translateY\(18%\)/);
|
|
assert.doesNotMatch(styles, /dialog\.rectification-board/);
|
|
assert.match(styles, /\.rectification-board__close \{[\s\S]*width: 44px/);
|
|
assert.match(styles, /\.rectification-board__close \{[\s\S]*min-height: 44px/);
|
|
assert.match(board, /<X aria-hidden="true"/);
|
|
assert.match(chat, /compactBoard && !boardOpen \? \(/);
|
|
assert.match(chat, /headerSlot && boardPeek \? createPortal\(boardPeek, headerSlot\)/);
|
|
assert.doesNotMatch(chat, /querySelector\(/);
|
|
assert.doesNotMatch(chat, /setHeaderSlot/);
|
|
assert.match(page, /className="chat-header-rectification"/);
|
|
assert.match(page, /data-rectification-header-slot=""/);
|
|
assert.match(page, /ref=\{setRectificationHeaderSlot\}/);
|
|
assert.match(page, /headerSlot=\{rectificationHeaderSlot\}/);
|
|
assert.match(styles, /\.chat-header-rectification \.rectification-board-peek \{[\s\S]*width: auto/);
|
|
assert.doesNotMatch(chat, /rectification-workspace__board-trigger/);
|
|
assert.doesNotMatch(chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("<form className=\"composer\"")), /RectificationBoardPeek/);
|
|
// Former lock: the workspace class expression ended at ` is-board-open`. The board now also
|
|
// carries `is-board-empty` before any candidate exists, so the empty board takes less width (BUG-483).
|
|
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}\$\{candidateResult \? "" : " is-board-empty"\}`\}/);
|
|
});
|
|
|
|
test("rectification composer can stop a live agent run", () => {
|
|
assert.match(chat, /signal: abortController\.signal/);
|
|
assert.match(chat, /runAbort\.current\?\.abort\(\)/);
|
|
// Former locks: `className="composer-stop"` and `aria-label="停止回答"` inside the chat file —
|
|
// the rectification-only composer. The stop control now comes from the shared ChatComposer
|
|
// (BUG-477); the chat passes the label and the handler.
|
|
assert.match(chat, /stopVisible=\{busy\}/);
|
|
assert.match(chat, /stopLabel="停止回答"/);
|
|
assert.match(chat, /onStop=\{stopRun\}/);
|
|
assert.match(readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"), /className="composer-stop"/);
|
|
assert.match(chat, /event\.type === "attempt\.reset"/);
|
|
assert.match(chat, /caught\.name === "AbortError"/);
|
|
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
|
|
});
|
|
|
|
test("conversation and house board reuse the quiet overlay scrollbar", () => {
|
|
assert.match(styles, /\[data-sidebar="content"\],\s*\n\.conversation,\s*\n\.rectification-board__body \{/);
|
|
assert.match(styles, /\.conversation \{[^}]*overflow-y:\s*auto/);
|
|
assert.doesNotMatch(styles, /\.conversation \{[^}]*scrollbar-gutter/);
|
|
assert.doesNotMatch(styles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \{[^}]*scrollbar-gutter/);
|
|
});
|
|
|
|
test("time-selection cards use server adoption state and stay mutually exclusive with choice cards", () => {
|
|
const messageLoop = chat.slice(
|
|
chat.indexOf("{messages.map((message) => {"),
|
|
chat.indexOf("{savedTime &&"),
|
|
);
|
|
const actionsIndex = messageLoop.indexOf("<ChatMessageActions");
|
|
const cardsIndex = messageLoop.indexOf("<RectificationCandidateCards");
|
|
assert.ok(actionsIndex >= 0 && cardsIndex > actionsIndex);
|
|
assert.match(chat, /candidateResult\?\.selectionAllowed/);
|
|
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*currentQuestion\?\.kind === "choice"[\s\S]*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/);
|
|
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*candidateResult\?\.canAdopt[\s\S]*!showLiveChoiceCard[\s\S]*!busy[\s\S]*!readonly/);
|
|
assert.doesNotMatch(
|
|
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const selectionCardMessageKey")),
|
|
/offeredSelectionOnce/,
|
|
);
|
|
assert.match(
|
|
chat,
|
|
/selectionCardMessageKey = showSelectionCards && latestSettledAssistant\s*\? latestSettledAssistant\.renderKey\s*: undefined/,
|
|
);
|
|
assert.match(messageLoop, /showSelectionCards && message\.renderKey === selectionCardMessageKey/);
|
|
assert.match(messageLoop, /<RectificationChoiceCard/);
|
|
assert.match(messageLoop, /choiceAttachment/);
|
|
assert.match(messageLoop, /choiceNonce/);
|
|
assert.match(messageLoop, /answered:\$\{settledChoice\.selectedKey\}/);
|
|
assert.doesNotMatch(choiceCardComponent, /setSelectedKey\(""\)/);
|
|
assert.match(choiceCardComponent, /selectedKey/);
|
|
assert.match(choiceCardComponent, /is-answered/);
|
|
assert.match(chat, /showLiveChoiceCard = Boolean\(/);
|
|
// Former lock: `isStructuredChoiceUserText` in the chat — it filtered the reader's tapped choice out
|
|
// of the persisted transcript, so after a refresh nobody could see what they had answered (BUG-482).
|
|
// The tap is now echoed as the reader's line, live and persisted alike.
|
|
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
|
|
assert.match(chat, /userVisibleChoiceLine\(answeredCard, optionId\)/);
|
|
assert.match(chat, /submitStructuredChoice\(CHOICE_ACTION/);
|
|
assert.match(chat, /submitStructuredChoice\(STOP_ACTION/);
|
|
assert.match(route, /answer_choice/);
|
|
assert.match(route, /stop_and_review/);
|
|
assert.match(route, /applyRectificationChoice\(accounting/);
|
|
assert.doesNotMatch(chat, /v9-choice-user-/);
|
|
assert.doesNotMatch(chat, /send\("message", choiceCardUserMessage/);
|
|
assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/);
|
|
assert.doesNotMatch(chat, /choiceCardUserMessage/);
|
|
assert.match(caseRoute, /choice_card: choiceCardFromCaseDossier/);
|
|
assert.match(caseRoute, /current_question: projectCurrentQuestion/);
|
|
assert.match(caseRoute, /overlayPublicDecision/);
|
|
assert.match(caseRoute, /interview: publicDecisionFields/);
|
|
assert.doesNotMatch(chat, /已完成验证/);
|
|
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
|
|
});
|
|
|
|
test("rectification keeps the composer but never renders generated suggestion chips", () => {
|
|
// Former lock: `<form className="composer"` written inline in the chat. The form now comes
|
|
// from the shared ChatComposer, with the same 500-character ceiling as the main chat (BUG-477).
|
|
assert.match(chat, /<ChatComposer/);
|
|
assert.match(chat, /maxLength=\{RECTIFICATION_COMPOSER_MAX_LENGTH\}/);
|
|
assert.match(chat, /const RECTIFICATION_COMPOSER_MAX_LENGTH = 500/);
|
|
assert.match(chat, /<CharacterRemaining/);
|
|
assert.doesNotMatch(chat, /composer-suggestions/);
|
|
assert.doesNotMatch(chat, /setSuggestions/);
|
|
assert.doesNotMatch(chat, /suggestions/);
|
|
assert.match(chat, /点上面的选项即可/);
|
|
});
|
|
|
|
test("does not parse suggestions from an incomplete run", () => {
|
|
assert.match(chat, /completed && !streamFailed \? parseAgentReply\(raw\)/);
|
|
assert.doesNotMatch(chat, /parseAgentReply\(partial/);
|
|
});
|
|
|
|
test("live answer.delta is the model reply, not a spoken-thinking split", () => {
|
|
assert.match(chat, /raw = event\.replace === true \? event\.text : raw \+ event\.text/);
|
|
assert.match(chat, /text: raw,/);
|
|
assert.doesNotMatch(chat, /settleRectificationSpokenAndThinking/);
|
|
assert.doesNotMatch(chat, /text: settled\.spoken/);
|
|
});
|
|
|
|
test("does not auto-submit a suggestion during render or recovery", () => {
|
|
assert.match(chat, /origin: action === "read_only" \? "choice_click" : "typed"/);
|
|
assert.doesNotMatch(chat, /origin: "suggestion_click"/);
|
|
assert.doesNotMatch(chat, /origin: "system_recovery"/);
|
|
assert.doesNotMatch(chat, /2002/);
|
|
});
|
|
|
|
test("persists message origin for every user turn", () => {
|
|
assert.match(route, /origin: z\.enum\(/);
|
|
assert.match(route, /messageOrigin:/);
|
|
assert.match(route, /clientActionId:/);
|
|
assert.match(chat, /clientActionId: requestId/);
|
|
assert.match(chat, /clientActionId: actionId/);
|
|
});
|
|
|
|
test("rectification Agent output stays natural and keeps tool execution silent", () => {
|
|
const skill = readFileSync(
|
|
new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const strategy = readFileSync(
|
|
new URL("../../skills/jyotish-birth-time-rectification/references/conversation-strategy.md", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(agent, /工具执行保持静默/);
|
|
assert.match(agent, /思考用简体中文写在思维链/);
|
|
assert.match(agent, /对用户说的话必须自己写在正文里/);
|
|
assert.doesNotMatch(route, /action === "message" && caseStatus === "paused"/);
|
|
assert.doesNotMatch(agent, /USER_STOP_PATTERN|USER_STOP_NEGATION_PATTERN/);
|
|
assert.match(agent, /skill_verification_report/);
|
|
assert.doesNotMatch(agent, /分盘句和宫位表由界面展示/);
|
|
assert.match(skill, /不得叙述读取 Skill/);
|
|
assert.match(skill, /完整回复可以(?:是)?零(?:个)?问题/);
|
|
assert.match(skill, /同一用户可以保留多个可恢复 Case/);
|
|
assert.doesNotMatch(skill, /同一用户最多一个 resumable Case/);
|
|
assert.match(strategy, /没有更多事件/);
|
|
assert.match(strategy, /(?:无需|不要求)结束、暂停或保存进度/);
|
|
assert.match(strategy, /不要一进场就出 A\/B\/C\/D/);
|
|
assert.match(agent, /有持久化 current_question 时,题干与选项完全由结构化槽位和 UI 承担/);
|
|
assert.match(agent, /正文不得提问、复述、改写或拼接题干/);
|
|
});
|
|
|
|
test("clear current-turn events go through the batch evidence service", () => {
|
|
const tools = readFileSync(
|
|
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(agent, /新事件走 rectification-record-evidence-batch/);
|
|
assert.doesNotMatch(agent, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
|
|
assert.doesNotMatch(agent, /“是\/对”只能确认当前 pending draft/);
|
|
assert.match(tools, /同一轮有两件及以上可拆分事件时必须改用 rectification-record-evidence-batch/);
|
|
assert.doesNotMatch(tools, /只有用户明确确认后才进入评分账本/);
|
|
});
|
|
|
|
test("the Agent prompt cannot offer candidates while asking for more evidence", () => {
|
|
assert.doesNotMatch(agent, /offer_selection/);
|
|
assert.doesNotMatch(agent, /id 不是 adopt_representative、validated_range、provisional_range 或 provisional_range_user_stopped 时不得调用 rectification-offer-candidates/);
|
|
const tools = readFileSync(
|
|
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(tools, /function agentToolKeysForDecision/);
|
|
assert.match(tools, /dropped_probes: inspectDiscriminatorProbes/);
|
|
assert.match(tools, /dropped_probes: decision.droppedProbes/);
|
|
assert.doesNotMatch(tools, /offer_selection/);
|
|
});
|
|
|
|
test("choice cards expose the persisted prompt as a visible legend", () => {
|
|
assert.match(choiceCardComponent, /<legend>\{props\.card\.prompt\}<\/legend>/);
|
|
assert.doesNotMatch(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
|
|
});
|
|
|
|
test("choice card answers and stop share one stacked primary list", () => {
|
|
assert.match(choiceCardComponent, /birth-time-primary-choices/);
|
|
assert.match(choiceCardComponent, /props\.card\.options\.map/);
|
|
assert.doesNotMatch(choiceCardComponent, /birth-time-special-choices/);
|
|
assert.doesNotMatch(choiceCardComponent, /is-secondary/);
|
|
});
|
|
|
|
test("adopted time offers a consultation handoff without unique-minute copy", () => {
|
|
assert.match(chat, /用这个时间看盘/);
|
|
assert.match(chat, /onStartConsultation/);
|
|
assert.match(board, /换升时刻/);
|
|
assert.match(board, /经历与大运对照/);
|
|
assert.match(chat, /<RectificationChoiceCard/);
|
|
assert.match(chat, /choiceAttachment/);
|
|
assert.match(page, /startConsultationAfterRectification/);
|
|
assert.match(page, /createSession\(modelCatalog\.defaultModelId\)/);
|
|
assert.doesNotMatch(agent, /start_consultation/);
|
|
assert.doesNotMatch(agent, /本会话以代表性时间收口|本轮校正已收口/);
|
|
});
|
|
|
|
test("stream failures remove empty assistant placeholders", () => {
|
|
assert.match(chat, /streamFailed = true/);
|
|
assert.match(chat, /const succeeded = completed && !streamFailed && Boolean\(parsed\.text\)/);
|
|
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
|
|
assert.match(chat, /if \(succeeded\)/);
|
|
});
|
|
|
|
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 === "restart"/);
|
|
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/);
|
|
});
|
|
|
|
|
|
test("legacy Skill adoption is an explicit server-owned route and case refresh exposes status", () => {
|
|
assert.match(adoptSkillRoute, /adoptLegacyRectificationSkill/);
|
|
assert.match(adoptSkillRoute, /invalid_adoption_request/);
|
|
assert.doesNotMatch(adoptSkillRoute, /upgradeRectificationSkill/);
|
|
assert.match(upgradeSkillRoute, /upgradeRectificationSkill/);
|
|
assert.doesNotMatch(upgradeSkillRoute, /adoptLegacyRectificationSkill/);
|
|
assert.match(caseRoute, /loadV9CaseSkillIdentityStatus/);
|
|
assert.match(caseRoute, /skill_identity_status/);
|
|
assert.match(caseRoute, /requires_skill_adoption/);
|
|
});
|