Files
Jyotisha/frontend/tests/rectification-opening-plain-20260926.test.ts
T
Jesse_ChenandClaude Opus 5.5 8fc1a5bded fix(rectification): plain-word opening with examples in the stem, plain de-duplicated step receipt (BUG-1049, BUG-1050)
- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and
  an example answer again and is server-owned on the zero-evidence opening;
  the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no
  question). Stem de-dup compares whole sentences / near-equality instead of a
  12-char prefix, which had deleted the body's examples sentence since
  aa7ccb30 (BUG-604) + dd8f35f7 (BUG-648).
- BUG-1050: plain step labels; a finished step label shows once and
  「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the
  in-progress wording.
- Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated.
- VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist
  and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 22:04:56 +08:00

287 lines
14 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.
/**
* 2026-09-26, staging e53052a2, real device (TASK-rectification-opening-plain-20260926):
*
* BUG-1049 (recurrence of BUG-504): the opening showed no examples. BUG-604
* moved the six examples out of the stem into the body's third sentence;
* BUG-648 then rewrote that sentence to begin with the stem's first 12
* characters, and `stripQuestionSentences` deleted any body sentence sharing
* that prefix — on the server, on GET, and (since BUG-1045) in the client
* merge. The body also spoke jargon (大运、盘面、代表分钟、精确到秒).
* BUG-1050: the step receipt spoke internal names (「读取校正记录」「设置对话焦点」)
* and listed the second set-focus call again.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
GENERIC_COLLECT_QUESTION,
OPENING_ANSWER_EXAMPLE,
OPENING_BODY_JARGON,
OPENING_COLLECT_DOMAINS,
isAcceptableOpeningBody,
openingSpokenBody,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
composeCollectSpokenAssistantText,
stripQuestionSentences,
} from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import {
buildMethodFollowupPlan,
isOpeningCollectFollowup,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { mergeTurnQuestions, type SnapshotTurnMessage } from "../src/lib/rectification-snapshot-messages.ts";
import {
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
RECTIFICATION_TOOL_DONE_LABELS,
RECTIFICATION_TOOL_PROGRESS_LABELS,
rectificationCompletedTrail,
} from "../src/lib/rectification-activity-labels.ts";
import {
completeActivityTraceStep,
emptyActivityTrace,
startActivityTraceStep,
} from "../src/lib/agent-activity-trace.ts";
import { rectificationTimelineRows } from "../src/lib/rectification-timeline-adapter.ts";
import { timelineSummaryLabel } from "../src/components/consultation-run-timeline.tsx";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CASE_ID,
TURN_ID,
USER_ID,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const RANGE = ["04:45", "05:15"] as const;
const BODY = openingSpokenBody(RANGE);
const BODY_SENTENCES = [
"我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。",
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
];
const EXAMPLES = ["上大学", "第一份工作", "搬到别的城市", "谈恋爱或结婚", "家里添丁", "生病住院"];
function occurrences(text: string, needle: string): number {
return text.split(needle).length - 1;
}
function openingFocus(askedTurnId: string | null): ConversationFocus {
return {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
caseId: CASE_ID,
questionId: "collect:other:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "other",
targetKind: null,
expectedAnswerSchema: { prompt: GENERIC_COLLECT_QUESTION, collect: true },
status: "active",
askedAt: "2026-09-26T08:00:00.000Z",
resolvedAt: null,
askedTurnId,
answerOption: null,
};
}
test("(a) the zero-evidence opening question slot carries 比如 + six examples + 例如 an example answer", () => {
const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup;
assert.ok(followup);
assert.equal(isOpeningCollectFollowup(followup, []), true);
const stem = spokenFollowupForUser(followup, []) ?? "";
assert.equal(stem, GENERIC_COLLECT_QUESTION);
assert.match(stem, /比如/);
assert.match(stem, /例如/);
for (const example of EXAMPLES) assert.ok(stem.includes(example), example);
assert.deepEqual([...OPENING_COLLECT_DOMAINS], EXAMPLES);
assert.ok(stem.includes(`「${OPENING_ANSWER_EXAMPLE}」`));
// The example answer is the only year in the stem and it is fixed copy, not
// derived from a birth date; the body stays year-free.
assert.deepEqual(stem.match(/(?:19|20)\d{2}/g), ["2015"]);
assert.doesNotMatch(BODY, /(?:19|20)\d{2}/);
});
test("(a) only the zero-evidence opening uses the example-bearing stem; once an event is dated it is gone", () => {
const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup;
assert.ok(followup);
const dated = [{
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2019-03-01",
occurredTo: null,
}];
assert.equal(isOpeningCollectFollowup(followup, dated), false);
assert.equal(isOpeningCollectFollowup({ ...followup, collect_retry: true }, []), false);
assert.notEqual(spokenFollowupForUser({ ...followup, collect_retry: true }, []), GENERIC_COLLECT_QUESTION);
});
test("(a) the opening set-focus stores the server stem even when the model rewords spokenPrompt", async () => {
const writes: Array<Record<string, unknown>> = [];
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [],
evidenceCount: 0,
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: [],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-26T00:00:00.000Z",
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes.push(args);
return {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: null,
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-09-26T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id,
idempotent: false,
};
},
});
const tool = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
})["rectification-set-focus"] as unknown as { execute(input: unknown): Promise<Record<string, unknown>> };
const result = await tool.execute({
caseId: CASE_ID,
questionId: "collect:other:collect_method_evidence",
intent: "collect_method_evidence",
// A valid rewording without examples — what the model tends to write.
spokenPrompt: "先说你最容易想起的一两件,年月大概就行。",
});
assert.equal((result as { error?: string }).error, undefined);
assert.equal(writes.length, 1);
const schema = writes[0]?.p_expected_answer_schema as Record<string, unknown>;
assert.equal(schema.prompt, GENERIC_COLLECT_QUESTION);
});
test("(b) server compose, GET attach and client merge keep both body sentences and show the stem once", () => {
// Server: a deterministic reply composes body + stem (BUG-969 ③).
const composed = composeCollectSpokenAssistantText(BODY, GENERIC_COLLECT_QUESTION);
assert.equal(occurrences(composed, GENERIC_COLLECT_QUESTION), 1);
for (const sentence of BODY_SENTENCES) assert.ok(composed.includes(sentence), sentence);
// Idempotent.
assert.equal(composeCollectSpokenAssistantText(composed, GENERIC_COLLECT_QUESTION), composed);
for (const stored of [BODY, composed]) {
// GET: the question is attached and the stem leaves the bubble.
const [turn] = attachQuestionsToTurns([
{ id: "t0", role: "assistant" as const, text: stored, status: "completed" },
], [openingFocus("t0")]);
assert.ok(turn);
assert.equal(turn.question?.prompt, GENERIC_COLLECT_QUESTION);
assert.equal(turn.text, BODY, "GET keeps the body whole");
const readerSees = `${turn.text}\n${turn.question?.prompt}`;
assert.equal(occurrences(readerSees, GENERIC_COLLECT_QUESTION), 1);
for (const example of EXAMPLES) assert.equal(occurrences(readerSees, example), 1, example);
// Client: the live bubble merged with the snapshot turn reads the same.
const live: SnapshotTurnMessage[] = [{ role: "assistant", turnId: "t0", text: stored, renderKey: "live" }];
const [merged] = mergeTurnQuestions(live, [turn]);
assert.equal(merged?.text, BODY, "client merge keeps the body whole");
assert.equal(merged?.question?.prompt, GENERIC_COLLECT_QUESTION);
}
});
test("(b) a body sentence that only shares an opening phrase with the stem is kept (the BUG-1049 deletion)", () => {
// Exactly what staging e53052a2 stored for the opening (Skill 10.0.30 copy).
const oldStem = "先说你最容易想起的一两件,年月大概就行。";
const oldExamples = "先说你最容易想起的一两件,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁或长辈住院、生病受伤,年月大概就行。";
const oldBody = `眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。${oldExamples}`;
assert.ok(stripQuestionSentences(oldBody, oldStem).includes(oldExamples));
// A model body that restates the stem verbatim (either sentence) still loses it.
const restated = `${BODY}${GENERIC_COLLECT_QUESTION}`;
assert.equal(stripQuestionSentences(restated, GENERIC_COLLECT_QUESTION), BODY);
const secondOnly = `${BODY}说个大概年月就行,例如「2015 年夏天换了工作」。`;
assert.equal(stripQuestionSentences(secondOnly, GENERIC_COLLECT_QUESTION), BODY);
// A near-copy (one or two words reworded) is still a restatement (BUG-585).
assert.equal(
stripQuestionSentences("记下了。家里如果有结婚、添丁或住院的事,记得大概哪年就行。", "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。"),
"记下了。",
);
});
test("(c) the opening body is plain: no jargon, no question, no years, no examples list", () => {
assert.equal(BODY, BODY_SENTENCES.join(""));
for (const word of ["大运", "盘面", "代表分钟", "精确到秒"]) {
assert.equal(BODY.includes(word), false, word);
assert.ok((OPENING_BODY_JARGON as readonly string[]).includes(word), word);
}
assert.doesNotMatch(BODY, /[??]/);
assert.equal(isAcceptableOpeningBody(BODY, RANGE), true);
assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。");
// The old fallback body is no longer acceptable from the model.
assert.equal(isAcceptableOpeningBody("眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。", RANGE), false);
assert.equal(isAcceptableOpeningBody("你好,我是生时校正助手。", RANGE), false);
assert.equal(isAcceptableOpeningBody(`${BODY}比如上大学、第一份工作、搬到别的城市。`, RANGE), false);
assert.equal(isAcceptableOpeningBody(openingSpokenBody(["04:50", "05:10"]), RANGE), false, "wrong window");
});
test("(d) repeated tool calls show one step each; the count is the shown steps; no internal names", () => {
let trace = emptyActivityTrace();
for (const [index, tool] of ([
"rectification-read-case",
"rectification-set-focus",
"rectification-set-focus",
] as const).entries()) {
trace = startActivityTraceStep(trace, tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool], index + 1);
trace = completeActivityTraceStep(trace, tool, RECTIFICATION_TOOL_DONE_LABELS[tool]);
}
assert.equal(trace.length, 3, "the live trace keeps one row per call");
const rows = rectificationTimelineRows({ trace, receipt: undefined, activity: undefined, settled: true });
assert.deepEqual(rows.map((row) => row.label), ["看了你的资料", "准备好下一个问题"]);
assert.equal(timelineSummaryLabel(rows, false), "已完成 2 步");
// Two tools that share a plain label are one step too.
let shared = emptyActivityTrace();
for (const tool of ["rectification-record-evidence-batch", "rectification-propose-evidence"] as const) {
shared = startActivityTraceStep(shared, tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool]);
shared = completeActivityTraceStep(shared, tool, RECTIFICATION_TOOL_DONE_LABELS[tool]);
}
assert.deepEqual(
rectificationTimelineRows({ trace: shared, receipt: undefined, activity: undefined, settled: true }).map((row) => row.label),
["记下你说的事"],
);
assert.equal(
rectificationCompletedTrail(["rectification-read-case", "rectification-set-focus", "rectification-set-focus"]),
"已完成:看了你的资料 · 准备好下一个问题",
);
const every = [
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
...Object.values(RECTIFICATION_TOOL_PROGRESS_LABELS),
...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS),
].join("\n");
for (const jargon of ["对话焦点", "校正记录", "事件证据", "候选", "稳健性", "焦点"]) {
assert.equal(every.includes(jargon), false, jargon);
}
assert.equal(RECTIFICATION_TOOL_DONE_LABELS["rectification-read-case"], "看了你的资料");
assert.equal(RECTIFICATION_TOOL_DONE_LABELS["rectification-set-focus"], "准备好下一个问题");
// In-progress lines reuse the VOICE-approved stage sentences (BUG-1047).
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-set-focus"], "正在准备下一个问题…");
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-propose-evidence"], "正在记下这件事…");
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-compare-candidates"], "正在重新对照盘面…");
});