- 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 sinceaa7ccb30(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
287 lines
14 KiB
TypeScript
287 lines
14 KiB
TypeScript
/**
|
||
* 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"], "正在重新对照盘面…");
|
||
});
|