Files
Jyotisha/frontend/tests/rectification-spoken-collect.test.ts
T
Jesse_Chen 360989ca2c fix(rectification): make every gap a live row with retries, give an empty Case a start, and say why a run stopped
The chat used to show "当前没有可回答的问题,正在等待服务端更新" / "当前问题暂时无法显示"
/ "题目加载失败,请刷新" as bare copy above the composer. The gap between a
settled turn and its next question is now a state: `preparing` renders the
same timeline live row the reply uses ("正在准备下一个问题…") and refetches the
Case on a 2s timer up to two retries; `unavailable` renders one line and a
44px reload button. A snapshot that never arrived at hydration is the same
gap. A resumed Case with no turns and no server-started opening shows
"这段校正还没有开始。" with one 开始提问 action. Stopping a run keeps what
streamed and says so instead of "暂时不可用"; the opening turn's live row
names what is happening; a 402 explains itself for 600ms before leaving.

BUG-505, BUG-507

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-03 07:07:31 +00:00

387 lines
17 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
isRenderableChoiceOpenQuestion,
openQuestionFromPersistedFocus,
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { spokenCollectFallbackFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
EVIDENCE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
import { parseV9CaseDossier as parseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
const RELATIONSHIP_PROMPT = USER_COLLECT_QUESTION.relationship;
const COLLECT_GET_QUESTION = {
question_id: "active_focus:active_focus",
kind: "collect_spoken",
intent: "collect_method_evidence",
domain: "relationship",
prompt: RELATIONSHIP_PROMPT,
};
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
return {
method_id: "active_focus",
intent: "collect_method_evidence",
ask_theme: "active_focus",
domain: "relationship",
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "active_focus",
...overrides,
};
}
function collectPersistResult(overrides: {
questionId?: string;
prompt?: string;
schema?: Record<string, unknown>;
domain?: string | null;
} = {}) {
const questionId = overrides.questionId ?? "collect:relationship:collect_method_evidence";
const prompt = overrides.prompt ?? RELATIONSHIP_PROMPT;
return {
status: "created" as const,
questionId,
prompt,
focus: {
id: FOCUS_ID,
caseId: CASE_ID,
questionId,
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: overrides.domain ?? "relationship",
targetKind: null,
expectedAnswerSchema: overrides.schema ?? {
prompt,
collect: true,
},
status: "active" as const,
askedAt: "2026-08-30T00:00:00.000Z",
resolvedAt: null,
},
};
}
test("skill version stays 10.0.14 for the spoken-collect visibility fix", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14");
});
test("cases current_question remains the submit contract, not a visual slot", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /current_question\?: unknown/);
assert.match(chat, /setCurrentQuestion\(nextQuestion\)/);
assert.doesNotMatch(chat, /className="rectification-question-slot"/);
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /<RectificationChoiceCard/);
assert.match(chat, /onSelect=\{submitChoice\}/);
assert.match(chat, /onStop=\{submitStop\}/);
assert.doesNotMatch(chat, /finalizeRectificationSpokenAndThinking/);
assert.doesNotMatch(chat, /rectification-agentic\/v9\/spoken-answer/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
test("collect_spoken stem lives on turn.question inside the same assistant article", () => {
// old → new → kept: slot sibling → in-bubble question → no body-text scan; refresh rebuilds from asked_turn_id
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /parseTurnQuestion\(turn\.question\)/);
assert.match(chat, /function markQuestionAnswered/);
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
assert.match(row, /afterAnswer/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
assert.doesNotMatch(chat, /function attachCollectSpokenStem/);
assert.match(chat, /useLayoutEffect\(\(\) => \{\s*currentQuestionRef\.current = currentQuestion;/);
assert.doesNotMatch(chat, /\[busy, currentQuestion, regeneratingMessageKey\]/);
assert.doesNotMatch(chat, /showCollectSpokenPrompt/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.doesNotMatch(chat, /showQuestionSlot/);
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
assert.match(
readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"),
/\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/,
);
assert.match(chat, /<RectificationChoiceCard/);
assert.doesNotMatch(chat, /请在下方输入框回答/);
assert.doesNotMatch(chat, /questionHintId/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(agentRun, /replace: true/);
assert.match(agentRun, /finalizeTurn\("completed", answerText/);
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
assert.match(styles, /\.rectification-message-question \{/);
assert.match(styles, /\.rectification-choice-card\.is-embedded/);
assert.doesNotMatch(styles, /\.rectification-question-slot \{/);
});
test("missing current_question is explicit only for resumable cases", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
// Was: `const showMissingQuestion = Boolean(... caseSnapshotLoaded ... resumableCase ...
// currentQuestion === null` plus the copy /当前没有可回答的问题,正在等待服务端更新/ and
// /题目加载失败,请刷新/. Those were bare waiting sentences; the same inputs now feed
// `rectificationQuestionGapState`, which yields a live row with retries and then a
// reload button, and is still gated on a resumable case (BUG-505).
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{[\s\S]*questionMissing: currentQuestion === null,[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /readonly && \(/);
assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
});
test("choice options render inside the same assistant message, not a sibling slot", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const render = chat.slice(
chat.indexOf("{messages.map((message) => {") ,
chat.indexOf("{savedTime &&"),
);
assert.match(chat, /showLiveChoiceCard/);
assert.match(render, /<RectificationChoiceCard/);
assert.match(render, /onSelect=\{submitChoice\}/);
assert.match(render, /onStop=\{submitStop\}/);
assert.match(render, /afterAnswer/);
assert.doesNotMatch(render, /rectification-question-slot/);
assert.doesNotMatch(render, /liveSpoken/);
assert.doesNotMatch(render, /showLiveSpokenCollect/);
});
test("openQuestionFromPersistedFocus returns collect_spoken without making collect choice-ready", () => {
const collect = openQuestionFromPersistedFocus(collectPersistResult());
assert.equal(collect?.kind, "collect_spoken");
assert.equal(collect?.prompt, RELATIONSHIP_PROMPT);
assert.equal(collect?.unrenderable, undefined);
assert.equal(isRenderableChoiceOpenQuestion(collect), false);
const choiceFocus = {
status: "created" as const,
questionId: "probe:education.2016",
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
focus: {
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "probe:education.2016",
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "education",
targetKind: null,
expectedAnswerSchema: {
choice: {
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: [
{ key: "A", label: "明确发生且时间吻合", answer_class: "yes" },
{ key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" },
{ key: "C", label: "明确没有发生", answer_class: "no" },
{ key: "D", label: "这段记不清楚", answer_class: "unsure" },
],
},
},
status: "active" as const,
askedAt: "2026-08-30T00:00:00.000Z",
resolvedAt: null,
},
};
const choice = openQuestionFromPersistedFocus(choiceFocus);
assert.equal(choice?.kind, "choice");
assert.equal(choice?.prompt, choiceFocus.prompt);
assert.notEqual(choice?.unrenderable, true);
assert.equal(isRenderableChoiceOpenQuestion(choice), true);
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const nextInterview = answerChoice.slice(
answerChoice.indexOf("export async function persistNextInterviewAfterChoice"),
answerChoice.indexOf("async function persistFocusAfterChoice"),
);
assert.match(nextInterview, /isRenderableChoiceOpenQuestion/);
assert.match(nextInterview, /hostNarration: open\.prompt/);
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
assert.match(route, /isRenderableChoiceOpenQuestion/);
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/);
});
test("collect focus keeps current_probe and inference.next_probe null; choice focus does not", () => {
const collectDossier = parseDossier(dossierFixture({
latestResult: candidateSnapshotFixture({
decisionReceipt: { inference_state: { next_probe: { id: "should-hide" } } },
}),
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "relationship",
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
},
}),
}),
}));
assert.ok(collectDossier);
const collectProjection = projectTurnDecision(collectDossier);
assert.equal((collectProjection.current_question as { kind?: string } | null)?.kind, "collect_spoken");
assert.equal(collectProjection.current_probe ?? null, null);
assert.equal((collectProjection.inference as { next_probe?: unknown } | null)?.next_probe ?? null, null);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const projection = tools.slice(
tools.indexOf("function agentVisibleLatestProjection"),
tools.indexOf("question_contract: {"),
);
assert.match(projection, /kind:\s*extras\.openQuestion\.kind|kind: extras\.openQuestion\.kind/);
assert.match(projection, /kind === "choice"/);
assert.match(projection, /next_probe: null/);
assert.match(projection, /current_probe: null/);
});
test("active_focus collect followups get a domain-stable questionId, not active_focus:active_focus", async () => {
const followup = collectFollowup();
assert.equal(stableFollowupQuestionId(followup).includes("active_focus:active_focus"), false);
assert.match(stableFollowupQuestionId(followup), /^collect:relationship:collect_method_evidence$/);
const degraded = spokenCollectFallbackFollowup({
...followup,
method_id: "dasha_events",
ask_theme: "dated_event",
domain: "education",
semantic_key: "education:2016",
probe_year: 2016,
});
assert.equal(stableFollowupQuestionId(degraded).includes("education:2016"), false);
assert.match(stableFollowupQuestionId(degraded), /^collect:education:collect_method_evidence$/);
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-30T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const stale = {
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "active_focus:active_focus",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
},
status: "active" as const,
askedAt: "2026-08-30T00:00:00.000Z",
resolvedAt: null,
};
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: stale,
decisionReceipt: null,
followup,
});
assert.equal(persisted.status, "created");
assert.notEqual(persisted.questionId, "active_focus:active_focus");
assert.match(persisted.questionId ?? "", /^collect:relationship:collect_method_evidence$/);
assert.equal(persisted.status, "created");
const open = openQuestionFromPersistedFocus(persisted);
assert.equal(open?.kind, "collect_spoken");
});
test("agent prompt lets the model write the stem only through set-focus spokenPrompt", () => {
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const prompt = agent.slice(
agent.indexOf("const agenticRectificationInstructions"),
agent.indexOf("export function getRectificationV9Agent"),
);
assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(prompt, /collect_spoken[\s\S]*不输出输入提示/);
assert.match(prompt, /没有下一问/);
assert.match(prompt, /选择卡|choice 选项由服务端/);
assert.doesNotMatch(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
assert.doesNotMatch(prompt, /collect_spoken 题干由服务器接在同一条正文末尾并写入聊天历史/);
assert.doesNotMatch(prompt, /必须由你用自己的话在正文里问出来/);
assert.doesNotMatch(prompt, /界面提示条/);
});
const COLLECT_QUESTION_ID = "collect:relationship:collect_method_evidence";
function collectFocus() {
return activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "relationship",
questionId: COLLECT_QUESTION_ID,
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
},
});
}
test("agent route keeps question ownership in the server Case projection", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const turnExit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
const beforeRun = route.slice(
route.indexOf("async start(controller)"),
route.indexOf("const result = await runV9AgentTurn"),
);
assert.match(beforeRun, /if \(action === "opening"\)/);
assert.match(beforeRun, /persistNextInterviewIfIdle/);
assert.match(afterRun, /await finalizeSuccessfulTurnExit/);
assert.match(turnExit, /persistNextInterviewIfIdle/);
assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/);
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
const successExit = afterRun.slice(afterRun.indexOf("} else {"), afterRun.indexOf("send({ type: \"done\""));
assert.match(successExit, /await finalizeSuccessfulTurnExit/);
assert.doesNotMatch(successExit, /send\(\{\s*type:\s*"error"/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus/);
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(agentRun, /askedTurnId: turnId/);
assert.doesNotMatch(agentRun, /当前可询问范围/);
});