Files
Jyotisha/frontend/tests/rectification-spoken-collect.test.ts
T
Jesse_ChenandClaude Fable 5.1 91b8b33aa6 fix(rectification): reveal the surface once, keep one live row across choice and adoption, and give an empty Case a start
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
2026-09-02 04:53:59 +00:00

360 lines
16 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 drives the unified question 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.match(chat, /className="rectification-question-slot"/);
assert.match(chat, /currentQuestion\?\.kind === "choice"/);
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 current_question renders the prompt in the question slot", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
assert.match(chat, /rectification-question-slot__prompt/);
// Former lock: `aria-describedby={collectSpokenPrompt` on the inline <Textarea>. The rectification
// surface now renders the shared ChatComposer (BUG-477) and hands the stem id in as `describedBy`,
// which the composer merges into the field's aria-describedby together with the count id.
assert.match(chat, /describedBy=\{collectSpokenPrompt && !showLiveChoiceCard && !readonly/);
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, /rectification-question-slot__spoken/);
assert.doesNotMatch(chat, /请在下方输入框回答/);
assert.doesNotMatch(chat, /questionHintId/);
assert.match(styles, /\.rectification-question-slot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
assert.match(styles, /\.rectification-question-slot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
assert.match(styles, /\.rectification-question-slot__prompt \{/);
assert.doesNotMatch(styles, /\.rectification-question-slot \.rectification-choice-card \{/);
});
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");
// Former locks: `const showMissingQuestion = Boolean(caseSnapshotLoaded … resumableCase … currentQuestion === null`
// and the copy 「当前没有可回答的问题,正在等待服务端更新」. That copy told the reader to wait with no
// action and no end (BUG-479); the gap is now a pure slot state — `preparing` with timed refetches,
// then `unavailable` with a reload — still only for resumable cases with a loaded snapshot.
assert.match(chat, /const questionSlot = rectificationQuestionSlotState\(\{[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
assert.doesNotMatch(chat, /等待服务端更新/);
assert.match(chat, /readonly && \(/);
assert.doesNotMatch(chat, /questionSlot[\s\S]*caseStatus.*TERMINAL/);
});
test("choice_card still renders through the existing choice-card branch", () => {
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(render, /showLiveChoiceCard/);
assert.match(render, /<RectificationChoiceCard/);
assert.match(render, /onSelect=\{submitChoice\}/);
assert.match(render, /onStop=\{submitStop\}/);
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 does not let the model write a persisted question stem", () => {
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, /有持久化 current_question 时/);
assert.match(prompt, /题干与选项完全由结构化槽位和 UI 承担/);
assert.match(prompt, /collect_spoken[\s\S]*不输出输入提示/);
assert.match(prompt, /没有 current_question 时也不要自拟区分题/);
assert.match(prompt, /选择卡/);
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"));
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|composeCollectSpokenAssistantText/);
});