Files
Jyotisha/frontend/tests/rectification-agentic-entry.test.ts
T
Jesse_ChenandClaude Opus 5.5 84eb23152a feat(rectification): remove the rectification regenerate button and endpoint (BUG-1056)
T2 of TASK-rectification-grounding-20260927 (product decision P1).
The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that
persisted unchecked text (invented range / fit rate reproduced). Removed:
the regenerate route, regenerate-turn.ts, the regeneration agent and its
read-only tool set, the client regenerate action/state and the
regenerating/canRegenerate props. ChatMessageActions renders the regenerate
button only when onRegenerate is passed; ordinary consultation is unchanged.
The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6,
retire in a later round). DESIGN.md / VOICE.md updated; source-contract
tests follow with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 03:28:27 +08:00

1013 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import { homeSurface as page } from "./home-surface.ts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
import {
abortWiringOf,
chatViewInput,
fixtureChoiceCard,
offerableCandidateResult,
RUN_FOCUS_ID,
settledAssistant,
} from "./rectification-chat-run-fixtures.ts";
import { deriveRectificationChatView } from "../src/lib/rectification-chat-view.ts";
import { completedReceiptFromPersisted } from "../src/lib/rectification-chat-messages.ts";
import { RectificationQuestionGapNotices } from "../src/components/rectification-question-gap-notices.tsx";
import { rectificationAgentRouteSurface } from "./rectification-agent-route-surface.ts";
import { rectificationAgentRunSurface } from "./rectification-agent-run-surface.ts";
const component = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
const messageEntry = readFileSync(
new URL("../src/components/rectification-message-entry.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",
);
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
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 deliveryCard = readFileSync(
new URL("../src/components/rectification-range-delivery.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 caseDossierResponse = readFileSync(
new URL("../src/lib/rectification-agentic/v9/case-dossier-response.ts", import.meta.url),
"utf8",
);
const caseProjection = `${caseRoute}\n${caseDossierResponse}`;
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",
);
// 原值: 模块级读取 cases/[caseId]/turns/[turnId]/regenerate/route.ts 供动作条用例断言。
// 新值: 不再读取;动作条用例改为断言该路由不存在、校正行不传 onRegenerate。
// 原因: BUG-1056(TASK-rectification-grounding-20260927 P1)删除生时校正的重新生成接口与按钮。
const regenerateRoutePath = new URL(
"../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts",
import.meta.url,
);
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,/);
// Was: key=...-${rectificationTurns.length > 0 ? "ready" : "loading"}. That suffix
// remounted the whole surface when the first turns arrived (an empty panel, then
// a flash), and again would have dropped a live stream; turns now hydrate before
// the switch and later arrive as a prop update (BUG-505).
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.doesNotMatch(page, /"ready" : "loading"/);
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
// Was: an inline `methods: Array.isArray((turn.receipt as { methods?: unknown }).methods)`
// parser in the surface hook; the parser moved to rectification-surface-state.ts so
// hydration and refresh share it (BUG-505).
assert.match(page, /setRectificationTurns\(parsePersistedRectificationTurns\(payload\?\.turns\)\)/);
assert.match(surfaceState, /methods: stringList\(receipt\.methods\)/);
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"/);
// 旧:enum 到 stop_and_review
// 新:增加 skip_probe(核对卡「这题跳过」)
// 原因:决策 4,跳过只关本题
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/);
// 原值:shouldStartOpening={rectificationShouldStartOpening} 写在 page.tsx JSX
// 新值:shouldStartOpening: rectificationShouldStartOpening 写在 hook 的 panel
// 原因:开场开关只服务子树,随 panel 下沉
assert.match(page, /shouldStartOpening: rectificationShouldStartOpening/);
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
// 原值:onOpeningConsumed={() => setRectificationShouldStartOpening(false)} 写在 page.tsx JSX
// 新值:onOpeningConsumed: () => setRectificationShouldStartOpening(false) 写在 hook 的 panel
// 原因:同上
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]*?assignOpenError\(/,
);
assert.match(opening, /function assignOpenError\(message: string\) \{\s*setRectificationError\(message\);/);
assert.match(
opening,
/catch \{[\s\S]*?assignOpenError\("生时校正会话暂时无法打开,请稍后重试。"\)/,
);
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\) \{/);
// Was: the "ready"/"loading" key suffix (see above). Persisted turns now survive
// because nothing remounts: the key is the session/Case binding only (BUG-505).
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
// 原值:initialTurns={rectificationTurns} / onMessagesChange={handleRectificationMessagesChange} / onOpeningConsumed={...} 写在 page.tsx JSX
// 新值:同名字段写在 hook 的 panel
// 原因:子树自有状态与回调收到 panel,Home 只传外壳还要读的绑定
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\);/);
// Was: `await loadCaseSnapshot(); choiceContinuationPending.current = true;` and an
// effect `if (!choiceContinuationPending.current || busy || readonly) return; void
// send("read_only", "")`. That hop dropped `busy` for a frame and removed the live
// row, so the next card flashed and the transcript went blank between the tap and
// the follow-up turn. Continuation now runs in the same async chain on the same
// row (BUG-506). The old `doesNotMatch` guarded against a *bare* `send("read_only",
// "")` after the snapshot — a continuation carries the row to reuse, and is kept.
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.doesNotMatch(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
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", () => {
// 原值: 读 lib/rectification-agentic/v9/agent-run.ts 单文件。新值: rectificationAgentRunSurface(agent-run.ts + 拆出的 agent-run-*.ts,见 rectification-agent-run-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const run = rectificationAgentRunSurface;
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/);
// 原值: if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream")
// 新值: batch completed → applyHostFallback;否则 empty_stream 仅在零写工具时 retryable
// 原因: BUG-633 决策 1/2,不得把 empty_stream 整表放回 RETRYABLE_ERROR_CODES
assert.match(run, /applyHostFallback/);
assert.match(run, /composeHostFallbackNarration/);
assert.match(run, /errorCode: "empty_stream"/);
assert.match(run, /if \(errorCode === "empty_stream"\) return status === "retryable"/);
assert.match(run, /if \(errorCode === "evidence_not_written"\) return status === "retryable"/);
assert.doesNotMatch(run, /if \(!answerText\.trim\(\)\) return failedAttempt\(attemptId, "empty_stream"\)/);
assert.doesNotMatch(run, /RETRYABLE_ERROR_CODES = new Set\(\[[^\]]*empty_stream/);
assert.doesNotMatch(run, /RETRYABLE_ERROR_CODES = new Set\(\[[^\]]*evidence_not_written/);
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\)/);
// 原值: !conversationAnchor.anchored
// 新值: conversationAnchor.latestBelowFold
// 原因: BUG-930 两个面同一套「本轮开头钉顶」,跳到最新看内容是否长出视口
assert.match(chat, /\{conversationAnchor\.latestBelowFold && \(\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/);
// 原值: gap: var(--space-2)
// 新值: gap: var(--consult-think-answer-gap)
// 原因: BUG-964 思考条与正文间距由同一 token 决定
assert.match(styles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--consult-think-answer-gap\)/);
assert.doesNotMatch(chat, /conversationEnd/);
// 原值: 改选滚到出卡消息
// 新值: 无 offerSectionRef;点其他行即改选
// 原因: BUG-595 决策 3
assert.doesNotMatch(chat, /offerSectionRef/);
assert.doesNotMatch(styles, /scrollIntoView/);
});
test("rectification keeps receipts for the varga sentence and shows live tool progress", () => {
// 原值: 从 chat 源码切 `function completedReceiptFromPersisted` 到 `export function RectificationAgenticChat`,
// 匹配 receipt.tools / receipt.methods / filter(isPublicRectificationTool) / filter(isPublicRectificationMethod),且不含 receipt.phases。
// 新值: 直接调用 completedReceiptFromPersisted:旧形状回执只留公开工具(去重)与公开方法,phases 不进步骤;
// tool_activities 形状只收 completed / failed 的公开工具与公开方法。
// 原因: TASK-rectification-code-split-20260926 把该函数原样搬到 rectification-chat-messages.ts;对返回值断言强于源码 token。
const legacyReceipt = completedReceiptFromPersisted({
status: "completed",
phases: ["case.loaded", "run.completed"],
tools: ["rectification-read-case", "internal-debug-tool", "rectification-read-case"],
methods: ["d1-rashi", "not-a-public-method"],
});
assert.deepEqual(legacyReceipt, { steps: ["rectification-read-case"], methods: ["d1-rashi"] });
const activityReceipt = completedReceiptFromPersisted({
status: "completed",
phases: ["case.loaded"],
tools: ["rectification-set-focus"],
tool_activities: [
{ tool: "rectification-read-case", status: "completed", methods: ["d1-rashi", "not-a-public-method"] },
{ tool: "internal-debug-tool", status: "completed" },
{ tool: "rectification-set-focus", status: "started" },
],
});
assert.deepEqual(activityReceipt, { steps: ["rectification-read-case"], methods: ["d1-rashi"] });
// 原值: 上述 token 都在 rectification-agentic-chat.tsx 的 messages.map 内。
// 新值: RenderMessage / 时间轴 / varga 句随行组件抽出。
// 原因: BUG-725 已结算行记忆化,派生不得留在容器每帧计算。
assert.match(messageEntry, /completedReceipt\?: CompletedActivityReceiptView/);
assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
assert.match(messageEntry, /vargaSentenceFromMethods/);
assert.match(messageEntry, /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\)/);
// 原值: /正在读取校正记录/、/正在比较候选时间/ / 新值: /正在看你的资料/、/正在重新对照盘面/ / 原因: BUG-1050 步骤名改大白话
assert.match(progressLabels, /正在看你的资料/);
assert.match(progressLabels, /正在重新对照盘面/);
assert.doesNotMatch(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 &&"),
);
// 原值: `<ChatMessageRow` 写在容器 messages.map 里。
// 新值: 行渲染在 `rectification-message-entry.tsx`,容器 map 只挂行组件。
// 原因: BUG-725 抽出 memo 行。
const replyIndex = messageEntry.indexOf("<ChatMessageRow");
assert.ok(replyIndex >= 0);
assert.match(messageRender, /<RectificationMessageEntry/);
assert.doesNotMatch(messageRender, /<RectificationHouseTableView/);
assert.doesNotMatch(messageEntry, /<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.
// 原值: chat 源码直接调用 rectificationTimelineRows。
// 新值: 调用点在行组件内。
// 原因: BUG-725 容器不再每帧派生时间轴行。
assert.match(messageEntry, /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/);
});
// 原值: 名称「… restore feedback, copy and safe in-place regeneration actions」;断言容器里有
// /turns/.../regenerate 请求与 randomUUID 请求号、重新生成路由不计费且按绑定 Skill 取包。
// 新值: 名称改为「feedback and copy, no regeneration」;断言校正行不传 onRegenerate、容器不再请求
// 重新生成、路由文件不存在。赞 / 踩 / 复制与普通对话的「重新生成回答」按钮断言保留。
// 原因: BUG-1056(TASK-rectification-grounding-20260927 P1)删除生时校正的重新生成接口与按钮(盲写且不校验就落库)。
test("completed Agent replies restore feedback and copy, no regeneration (BUG-1056)", () => {
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(messageActions, new RegExp(`aria-label="${label}"`));
}
assert.match(messageActions, /\{onRegenerate && \(/);
// 原值: `<ChatMessageActions` 写在容器 messages.map 里。
// 新值: 动作条随行组件抽出;容器仍切换 feedback。
// 原因: BUG-725 动作回调走 actionsRef,组件本身仍要渲染动作条。
assert.match(messageEntry, /<ChatMessageActions/);
assert.match(chat, /toggleRectificationFeedback/);
assert.match(chat, /navigator\.clipboard\.writeText\(copyTextForMessage\(message\.text, message\.question\)\)/);
assert.doesNotMatch(chat, /\/regenerate/);
assert.doesNotMatch(messageEntry, /onRegenerate|canRegenerate|regenerat/);
assert.equal(existsSync(regenerateRoutePath), false);
const messageRender = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
const replyIndex = messageEntry.indexOf("<ChatMessageRow");
const actionsIndex = messageEntry.indexOf("<ChatMessageActions");
assert.ok(replyIndex >= 0 && actionsIndex >= 0);
assert.ok(replyIndex < actionsIndex);
assert.doesNotMatch(messageRender, /<CompletedActivityReceipt/);
assert.doesNotMatch(messageEntry, /<CompletedActivityReceipt/);
assert.doesNotMatch(chat, /send\("message",\s*(?:oldText|previousText)/);
});
test("candidate state renders from the snapshot API and never from sentinels", () => {
assert.match(deliveryCard, /rangeDeliveryTitle/);
assert.doesNotMatch(chat, /本会话以代表性时间收口,不确认唯一分钟/);
assert.doesNotMatch(chat, /当前排盘时间(代表性时间,本会话不确认唯一分钟)/);
assert.doesNotMatch(chat, /相对支持度/);
assert.doesNotMatch(deliveryCard, /相对支持度/);
assert.doesNotMatch(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/);
// 原值: 分歧面板三列
// 新值: 按分钟行选,无 divergence-list
// 原因: BUG-595 决策 1
assert.doesNotMatch(styles, /\.rectification-divergence-list \{/);
assert.doesNotMatch(chat, /relativeSupport}\%/);
assert.doesNotMatch(chat, /相对支持度 \{candidate\.relativeSupport\}/);
assert.doesNotMatch(chat, /isRecommendedRectificationCandidate/);
assert.match(deliveryCard, /rangeDeliveryAdopted/);
assert.match(deliveryCard, /rangeDeliveryAdopting/);
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 &&"),
);
// A slice whose end marker has gone missing silently becomes `slice(i, -1)`,
// i.e. almost the whole file, and every doesNotMatch below stops meaning what
// it says. Fail loudly on a renamed marker instead.
const sliceBetween = (source: string, from: string, to: string): string => {
const start = source.indexOf(from);
const end = source.indexOf(to);
assert.ok(start >= 0, `marker not found: ${from}`);
assert.ok(end > start, `marker not found after ${from}: ${to}`);
return source.slice(start, end);
};
// 原值: snapshot 切到 `{scoringMinutes.length > 0 || displayMinutes.length > 0`,
// 并断言 `<RectificationHouseTableView` 就在 snapshot 里。
// 新值: snapshot 切到 `{hasMinuteDetail && (`,宫位表改在「参数与口径」段里断言。
// 原因: D10 把盘面五个折叠块收敛成「候选分钟」与「参数与口径」两段,本命宫位表
// 随之从 `.rectification-snapshot` 移进「参数与口径」。旧终点标记已不存在,
// 留着会让 slice 退化成整份文件。宫位表仍在盘面、不在消息流,断言不变。
const snapshot = sliceBetween(board, "className=\"rectification-snapshot\"", "{hasMinuteDetail && (");
const parameterSection = board.slice(board.indexOf("{hasParameterDetail && ("));
// 原值: `<ChatMessageRow` 在容器 map 循环里。
// 新值: 循环渲染 `RectificationMessageEntry`,ChatMessageRow 在行组件内。
// 原因: BUG-725。
const rowIndex = messageLoop.indexOf("<RectificationMessageEntry");
assert.ok(rowIndex >= 0);
// The chart is what stays unfolded; the table is one click into 参数与口径.
assert.match(snapshot, /<VedicChartSvg/);
assert.doesNotMatch(snapshot, /<RectificationHouseTableView/);
assert.ok(parameterSection.indexOf("<RectificationHouseTableView") > 0);
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,
/if \(succeeded\) \{[\s\S]*await loadCaseSnapshot\([\s\S]*\} else \{[\s\S]*await loadCaseSnapshot\(/,
);
assert.match(chat, /questionPersisted: Boolean\(currentQuestion\?\.prompt && questionSource === "focus"\)/);
assert.match(chat, /questionGap === "persisted_question"/);
assert.match(chat, /data-testid="persisted-question"/);
assert.match(chat, /rectificationReadonlyRangeCopy/);
assert.doesNotMatch(chat, /还在收窄/);
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\}/);
// 原值:headerSlot={rectificationHeaderSlot} 写在 page.tsx JSX
// 新值:headerSlot: rectificationHeaderSlot 写在 hook 的 panel;page 仍 ref={setRectificationHeaderSlot}
// 原因:header 节点在外壳,值只服务子树
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/);
// Was: the class template without `is-board-empty`. The board narrows while no
// candidate exists (BUG-509); compact/open modifiers are unchanged and still
// follow the empty modifier so the single-column layout wins.
assert.match(chat, /className=\{`rectification-workspace\$\{candidateResult \? "" : " is-board-empty"\}\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}`\}/);
});
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"/);
// 原值:caught.name === "AbortError"
// 新值:isAbortError(caught)
// 原因:选择题与采用路径共用同一中止判定(BUG-552)
assert.match(chat, /isAbortError\(caught\)/);
// 原值: 容器 map 写 `showActivity={displayedMessage.state !== "settled"}`。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
});
test("aborting a live run marks the bubble stopped without an alert", async () => {
assert.match(chat, /failed: false,\s*stopped: true/);
// 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /role="alert"[\s\S]{0,80}RECTIFICATION_STOPPED_NOTICE/);
// 原值: 从 chat 源码切 submitStructuredChoice / acceptCandidate 两段,各匹配 `runAbort.current = abortController` 与 `signal: abortController.signal`。
// 新值: 调用 runRectificationStructuredChoice / runRectificationCandidateAccept(假 fetch):请求带的 signal
// 就是 runAbort 里那个控制器的(停止按钮能掐断它),结束后 runAbort 清空。
// 原因: TASK-rectification-code-split-20260926 两段函数体原样搬进参数函数;行为断言覆盖接线本身。
const choiceWiring = await abortWiringOf("choice");
assert.equal(choiceWiring.url, "/api/rectification/agent");
assert.equal(choiceWiring.signalMatchesRunAbort, true);
assert.equal(choiceWiring.runAbortCleared, true);
const adoptWiring = await abortWiringOf("accept");
assert.match(adoptWiring.url, /\/api\/rectification\/cases\/[^/]+\/candidates\/accept$/);
assert.equal(adoptWiring.signalMatchesRunAbort, true);
assert.equal(adoptWiring.runAbortCleared, true);
});
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 &&"),
);
// 原值: ChatMessageActions 与 RangeDelivery 都在容器 map 里,且 actions 在卡之前。
// 新值: 动作条在行组件内;交付卡仍在容器 map、排在行组件之后。
// 原因: BUG-725 行组件抽出,交付卡不属于逐消息 Markdown 边界。
const entryIndex = messageLoop.indexOf("<RectificationMessageEntry");
const cardsIndex = messageLoop.indexOf("<RectificationRangeDelivery");
assert.ok(entryIndex >= 0 && cardsIndex > entryIndex);
assert.match(messageEntry, /<ChatMessageActions/);
// 旧:showSelectionCards 只看 selectionAllowed && canAdopt && !showLiveChoiceCard && !busy,
// 锚在 latestSettledAssistant。采集题没有 A–D 卡,所以 collect_evidence 会出采用卡;
// 采用后 busy 把卡藏掉,新消息又把卡带走。
// 新:canShowRectificationSelectionCards(含 session_outcome 门)+ candidateOffer 所有权;
// 未采用时卡跟着最新采用旁白走,采集题还在问则不出卡;已采用后不再把卡锚到最新消息。
// 采用中 / 已采用时 busy 不得整张卸卡;收尾句跟卡片同一列。
// 保留语义:卡片仍由服务端 can_adopt / selection_allowed / receipt 授权,前端不自造候选。
assert.match(chat, /canShowRectificationSelectionCards/);
assert.match(chat, /applyLiveCandidateOffer/);
// 原值: chat 源码含 persistedOfferFromTurn(mergeTurnQuestions 写在组件内)
// 新值: 合并抽到 rectification-snapshot-messages.ts,仍调用 persistedOfferFromTurn
// 原因: BUG-685 要把新 turn 并进对话区,合并逻辑必须可单测
const snapshotMessages = readFileSync(new URL("../src/lib/rectification-snapshot-messages.ts", import.meta.url), "utf8");
assert.match(snapshotMessages, /persistedOfferFromTurn/);
assert.match(chat, /applySnapshotTurnsToMessages|mergeTurnQuestions/);
assert.match(chat, /resolveSelectionCardMessageKey/);
assert.match(chat, /keepSelectionCardsWhileBusy/);
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/);
// 原值: 从 chat 源码切 `const showSelectionCards` 到 `const collectSpokenPrompt`:含
// `!busy || keepSelectionCardsWhileBusy`,不含 `offeredSelectionOnce`。
// 新值: 调用 deriveRectificationChatView:busy 收卡、busy 但正在采用留卡;同一输入先后派生两次结果相同,
// 出过一次卡不会留下隐藏记忆。
// 原因: TASK-rectification-code-split-20260926 派生逻辑原样搬进纯函数 rectification-chat-view.ts。
const offerable = offerableCandidateResult();
const offerMessages = [settledAssistant("offer-1")];
const offerIdle = chatViewInput({ messages: offerMessages, candidateResult: offerable });
assert.equal(deriveRectificationChatView(offerIdle).showSelectionCards, true);
assert.equal(deriveRectificationChatView({ ...offerIdle, busy: true }).showSelectionCards, false);
assert.equal(
deriveRectificationChatView({ ...offerIdle, busy: true, acceptingCandidateId: offerable.candidates[0].candidateId }).showSelectionCards,
true,
);
// 出过卡、采用中之后再来一轮 busy:仍收卡,没有 offeredSelectionOnce 式的记忆。
assert.equal(deriveRectificationChatView({ ...offerIdle, busy: true }).showSelectionCards, false);
assert.match(chat, /!candidateResult\?\.selectedTime \? latestSettledAssistant\?\.renderKey/);
// 原值: showSelectionCards && message.renderKey === selectionCardMessageKey
// 新值: 还要有 candidateResult 才渲染行选卡
// 原因: BUG-595 决策 1
assert.match(messageLoop, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/);
// 原值: 嵌入卡 token(RectificationChoiceCard / variant / choiceNonce / answer_option)在容器 map。
// 新值: 嵌入卡在行组件;容器 map 仍有 persisted 题卡的 RectificationChoiceCard。
// 原因: BUG-725。
assert.match(messageEntry, /<RectificationChoiceCard/);
assert.match(messageEntry, /variant="embedded"/);
assert.match(messageEntry, /choiceNonce/);
assert.match(messageEntry, /question\.answer_option/);
// 原值: messageLoop(容器 map 到 savedTime 之间)含 persisted 题卡 <RectificationChoiceCard / variant="embedded" / choiceNonce。
// 新值: messageLoop 挂 <RectificationQuestionGapNotices 并传 choiceNonce;真渲染该块:persisted 选择题出嵌入卡(is-embedded、四个选项)。
// 原因: TASK-rectification-code-split-20260926 缺题块(含 persisted 题卡)抽成组件,位置不变。
assert.match(messageLoop, /<RectificationQuestionGapNotices/);
assert.match(messageLoop, /choiceNonce=\{choiceNonce\}/);
const persistedCard = fixtureChoiceCard();
const persistedHtml = renderToStaticMarkup(createElement(RectificationQuestionGapNotices, {
view: { showStandaloneQuestion: true, questionGap: "persisted_question", showSelectionCards: false, verifiedIdleCopy: null },
currentQuestion: { kind: "choice", prompt: persistedCard.prompt, focus_id: RUN_FOCUS_ID, question_id: persistedCard.question_id },
choiceCard: persistedCard,
choiceNonce: 0,
busy: false,
readonly: false,
snapshotFailure: null,
questionRepairAttempts: 0,
questionRepairing: false,
submitChoice() {},
submitStop() {},
async refetchQuestion() {},
async repairQuestion() {},
}));
assert.match(persistedHtml, /data-testid="persisted-question"/);
assert.match(persistedHtml, /class="rectification-choice-card is-embedded"/);
assert.equal(persistedHtml.match(/class="birth-time-choice-option is-primary"/g)?.length, 4);
assert.doesNotMatch(messageLoop, /rectification-question-slot/);
assert.doesNotMatch(persistedHtml, /rectification-question-slot/);
assert.doesNotMatch(messageEntry, /rectification-question-slot/);
assert.doesNotMatch(choiceCardComponent, /setSelectedKey\(""\)/);
assert.match(choiceCardComponent, /selectedKey/);
assert.match(choiceCardComponent, /is-answered/);
assert.match(chat, /showLiveChoiceCard = Boolean\(/);
assert.match(chat, /isStructuredChoiceUserText/);
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/);
// 原值: 直接卡片投影;新值: 只读时 null;原因: BUG-984 不能选择旧结果。
assert.match(caseProjection, /choice_card: identity\.read_only \? null : choiceCardFromCaseDossier/);
// 原值: 容器 map 用 `choiceCard && choiceCard.focus_id === question.focus_id` 和 `disabled={!liveQuestion}`。
// 新值: 行组件用 liveChoiceCard / liveQuestion;容器的 live 卡判定仍比对 latestLiveQuestion。
// 原因: BUG-725。
assert.match(messageEntry, /liveChoiceCard\s+&& liveChoiceCard\.focus_id === question\.focus_id/);
assert.match(messageEntry, /disabled=\{!liveQuestion\}/);
assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === latestLiveQuestion\.focus_id/);
// 原值: 无条件问题;新值: 只读不提供可写问题;原因: BUG-984。
assert.match(caseProjection, /current_question: identity\.read_only \? null : projectCurrentQuestion/);
assert.match(caseProjection, /overlayPublicDecision/);
assert.match(caseProjection, /const fields = publicDecisionFields\(decision\)/);
// 原值: 断言读 cases/[caseId]/route.ts 的 interview 投影
// 新值: 断言读 case-dossier-response.ts(GET 仍调用 dossierResponse)
// 原因: Next App Router 不允许 route.ts 导出非 HTTP handler。
assert.match(caseProjection, /interview: \{\s*\.\.\.fields,/);
assert.match(caseProjection, /collection_progress: collectionProgressFromReceipt/);
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/);
// 原值:校正面自己渲染 <CharacterRemaining> / 新值:计数由 ChatComposer 内联渲染
// / 原因:`.composer-footer` 常驻横栏已删,两个调用方都改用 toolbar 插槽。
assert.match(chat, /toolbar=\{\(/);
assert.doesNotMatch(chat, /composer-footer/);
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, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
// 旧:正文只做承接(2-4 句)→ 新:正文只做承接,证据轮一句复述
// 原因:BUG-606 每轮只留一句话,不再给 2-4 句额度
assert.match(agent, /正文只做承接,不提问、不复述题干、不预告选项/);
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/);
const persist = readFileSync(
new URL("../src/lib/rectification-agentic/v9/score-persist.ts", import.meta.url),
"utf8",
);
assert.match(persist, /dropped_probes: inspectDiscriminatorProbes/);
assert.match(tools, /dropped_probes: decision.droppedProbes/);
assert.doesNotMatch(tools, /offer_selection/);
});
test("embedded choice cards hide the prompt legend; standalone cards keep it visible", () => {
assert.match(choiceCardComponent, /<legend>\{props\.card\.prompt\}<\/legend>/);
assert.match(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
assert.match(choiceCardComponent, /variant === "embedded"/);
});
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", () => {
// 旧 状态条含「用这个时间看盘」和 onStartConsultation → 新 产品删除该按钮 → 保留 不得再写唯一分钟收口文案
assert.doesNotMatch(chat, /用这个时间看盘/);
assert.doesNotMatch(chat, /onStartConsultation/);
assert.match(board, /换升时刻/);
assert.match(board, /经历与大运对照/);
assert.match(chat, /<RectificationChoiceCard/);
assert.match(chat, /markQuestionAnswered/);
assert.doesNotMatch(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"/)
// 新值:首页小字改由 rectificationEntryHint 直接读 entry summary 的 hasResumableCase;
// 「restart」分支对应的那句已删除,改锁 hint 读的是 summary 本身、不读会话列表
// 原因:TASK-starter-home-polish-20260926 D2 删掉「上次已经校正完…」一句;BUG-1041 查明
// `=== "restart"` 从未命中(解析器读错键名)。「CTA 与提示由服务端 entry summary 驱动」这条意图不变。
assert.match(page, /rectificationEntryHint\(rectificationForSelf, rectificationEntrySummary\)/);
assert.match(page, /summary\?\.hasResumableCase/);
assert.doesNotMatch(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(caseProjection, /skill_identity_status/);
assert.match(caseProjection, /requires_skill_adoption/);
});