merge origin/staging into report chart render branch
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Keep BUG-607 after 602–606 and retain both 09-09 changelog entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 15:13:37 +08:00
co-authored by Cursor
67 changed files with 1826 additions and 172 deletions
@@ -4,13 +4,16 @@ import test from "node:test";
import {
GENERIC_COLLECT_QUESTION,
OPENING_COLLECT_DOMAINS,
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
accidentCaseHardcodedTurns,
containsBoundarySemantics,
engineMeaningToDisplayCopy,
isAcceptableOpeningBody,
listUserVisibleCopy,
nonConvergingRangeNarration,
openingSpokenBody,
stopReasonPrefix,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
@@ -114,6 +117,7 @@ test("delivery range narration puts the stop reason before the progress clause",
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectDeclinedAck));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectSkippedAck));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.probePoolExhaustedStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.insufficientEventsGate));
assert.equal(stopReasonPrefix("probe_pool_exhausted"), RECTIFICATION_USER_COPY.probePoolExhaustedStop);
@@ -127,10 +131,15 @@ test("question stem ownership stays on set-focus spokenPrompt, not a slot or a s
assert.match(agent, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(agent, /题干会作为同一条消息的下一段自动出现/);
assert.match(agent, /开场轮:先 set-focus 写采集题的 spokenPrompt/);
assert.match(agent, /不要举大学、工作、搬家的例子/);
assert.match(agent, /每轮正文 2-4 句/);
// 旧:不要举大学、工作、搬家的例子 / 每轮正文 2-4 句 / 接下来我们继续
// 新:开场点六类、证据轮一句复述
// 原因:BUG-604 / BUG-606
assert.match(agent, /升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤/);
assert.match(agent, /证据轮正文只写一句复述/);
assert.match(agent, /记下了:2016 年 9 月入学、2020 年 6 月毕业/);
assert.match(agent, /正文不得断言界面当前状态/);
assert.match(agent, /接下来我们继续/);
assert.doesNotMatch(agent, /每轮正文 2-4 句/);
assert.doesNotMatch(agent, /不要举大学、工作、搬家的例子/);
assert.doesNotMatch(agent, /题干与选项完全由结构化槽位和 UI 承担/);
assert.doesNotMatch(agent, /collect_spoken 题干由服务器接在同一条正文末尾/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
@@ -256,3 +265,18 @@ test("skill forbids computing D9/D10 signs from transition clocks", () => {
assert.match(skill, new RegExp(sentence.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(comparison, new RegExp(sentence.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
test("opening body lists domains without years and the stem no longer lists year examples", () => {
const body = openingSpokenBody(["04:45", "05:15"]);
assert.equal(isAcceptableOpeningBody(body), true);
assert.ok(OPENING_COLLECT_DOMAINS.filter((domain) => body.includes(domain)).length >= 5);
assert.doesNotMatch(body, /(?:19|20)\d{2}/);
assert.match(body, /最后给区间和代表分钟,不给精确到秒/);
assert.equal(GENERIC_COLLECT_QUESTION, "先说你最容易想起的一两件,年月大概就行。");
assert.doesNotMatch(GENERIC_COLLECT_QUESTION, /比如/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectSkippedAck));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.firstDatedCollectInvite));
assert.doesNotMatch(listUserVisibleCopy().join("\n"), /还有吗|还能想起别的吗/);
assert.ok(MACHINE_VOICE_LEXICON.includes("领先"));
assert.ok(MACHINE_VOICE_LEXICON.includes("落后"));
});
+5 -1
View File
@@ -74,7 +74,11 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(messageRowSource, /stackedThinkingAndAnswer/);
assert.match(messageRowSource, /aria-label="回复"/);
assert.match(messageRowSource, /className="message-stage-and-answer"/);
assert.match(messageRowSource, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
// 旧:vargaSentence 在气泡正文,折叠思考时才藏
// 新:正文不再渲染方法句;展开的活动时间线保留
// 原因:BUG-606 决策 3(a)
assert.doesNotMatch(messageRowSource, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
assert.match(messageRowSource, /<ConsultationRunTimeline[\s\S]*vargaSentence=\{vargaSentence\}/);
assert.match(globalStyles, /\.message-stage-and-answer/);
assert.match(globalStyles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
@@ -706,7 +706,10 @@ test("rectification Agent output stays natural and keeps tool execution silent",
assert.match(strategy, /(?:无需|不要求)结束、暂停或保存进度/);
assert.match(strategy, /不要一进场就出 A\/B\/C\/D/);
assert.match(agent, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(agent, /正文只做承接(2-4 句),不提问、不复述题干、不预告选项/);
// 旧:正文只做承接(2-4 句)→ 新:正文只做承接,证据轮一句复述
// 原因:BUG-606 每轮只留一句话,不再给 2-4 句额度
assert.match(agent, /正文只做承接,不提问、不复述题干、不预告选项/);
assert.match(agent, /证据轮正文只写一句复述/);
});
test("clear current-turn events go through the batch evidence service", () => {
@@ -30,7 +30,7 @@ import {
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { RECTIFICATION_TERMINATION_COPY, ADOPT_OUTCOMES } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import {
evidenceLedgerFingerprint,
parseV9CaseDossier,
@@ -729,7 +729,7 @@ test("clicking A applies the choice without invoking a language model", async ()
start: "2015-01-01",
end: "2015-12-31",
});
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /已记录/);
// 原值: 旁白含 05:0005:10 或「眼下更像 05:00」,且不再问家人
// 新值: 下一问是家人采集
// 原因: BUG-559 无同域带年月锚点的 varga_style 卡被丢掉;账本只有事业,下一问按方法轮转到家人
@@ -809,7 +809,7 @@ test("answering a discriminator persists the next dated card so GET still has a
const persistedPrompt = schema.choice?.prompt;
assert.ok(persistedPrompt);
assert.notEqual(applied.narration, persistedPrompt);
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /已记录/);
assert.doesNotMatch(applied.narration, /2014/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_assistant_message, applied.narration);
@@ -978,18 +978,23 @@ test("answering the last discriminator persists a year-locked family collect foc
const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean };
assert.equal(schema.choice, undefined);
assert.equal(schema.collect, true);
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family → 决策 7 采集题不带年份前缀
assert.equal(schema.prompt, RECTIFICATION_USER_COPY.collectQuestionByDomain.family);
// 旧:prompt 等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604;年份仍不进采集题干
assert.equal(
schema.prompt,
withFirstDatedCollectInvite(RECTIFICATION_USER_COPY.collectQuestionByDomain.family),
);
assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/);
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, false);
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /已记录/);
assert.doesNotMatch(applied.narration, /2021/);
assert.doesNotMatch(applied.narration, /点选/);
assert.doesNotMatch(applied.narration, /D24/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.match(String(turn?.args.p_assistant_message ?? ""), /已记录你的选择/);
assert.match(String(turn?.args.p_assistant_message ?? ""), /已记录/);
assert.doesNotMatch(String(turn?.args.p_assistant_message ?? ""), /2021/);
assert.equal(
accounting.calls.some((call) => (
@@ -1047,7 +1052,7 @@ test("keeps the applied answer when narration persistence fails", async () => {
assert.equal(applied.applied, true);
assert.equal(applied.status, "applied");
assert.equal(applied.narrationPersisted, false);
assert.match(applied.narration, /已记录你的选择/);
assert.match(applied.narration, /已记录/);
});
test("stop_and_review does not write an inference transition", async () => {
@@ -1301,9 +1306,12 @@ test("a scoring choice narrates cluster movement and range change", () => {
credibleBefore: ["04:31", "05:07"],
credibleAfter: ["04:31", "04:39"],
});
assert.match(narrowed, /领先/);
assert.match(narrowed, /落后/);
assert.match(narrowed, /范围从 04:3105:07 收到 04:3104:39/);
// 旧:领先/落后 +「范围从 04:3105:07 收到 04:3104:39」
// 新:已记录,范围收到 A–B。不变则「范围没变」。变宽写「变为」。
// 原因:BUG-606 决策 3(b),旁白不再讲哪段领先落后
assert.equal(narrowed, "已记录,范围收到 04:3104:39。");
assert.doesNotMatch(narrowed, /领先|落后/);
assert.doesNotMatch(narrowed, /范围从 /);
const unchanged = composeChoiceNarration({
optionId: "A",
scoring: true,
@@ -1313,6 +1321,14 @@ test("a scoring choice narrates cluster movement and range change", () => {
credibleAfter: ["04:31", "04:39"],
});
assert.match(unchanged, /范围没变/);
const widened = composeChoiceNarration({
optionId: "A",
scoring: true,
appliedInference: true,
credibleBefore: ["04:45", "04:50"],
credibleAfter: ["04:40", "05:00"],
});
assert.equal(widened, "已记录,范围变为 04:4005:00。");
const unsure = composeChoiceNarration({
optionId: "D",
scoring: true,
@@ -1353,8 +1369,12 @@ test("answering A narrates the credible range even when the search window stays
optionId: "A",
expectedRevision: previous.revision,
});
assert.match(applied.narration, /范围从 04:3105:07 收到 04:3104:31/);
// 旧:整段旁白等于「已记录,范围收到 04:3104:31。」
// 新:旁白以该句开头;若下一问未能单独落库,服务端仍可能把采集题干接在后面
// 原因:BUG-606 决策 3(b) 锁的是点选旁白,不是下一问是否挂上同一条消息
assert.match(applied.narration, /^已记录,范围收到 04:3104:31。/);
assert.doesNotMatch(applied.narration, /范围没变/);
assert.doesNotMatch(applied.narration, /领先|落后/);
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
assert.match(source, /credibleBefore:\s*previous\.credible_range/);
assert.match(source, /credibleAfter:\s*applied\.state\.credible_range/);
@@ -307,4 +307,40 @@ test("session_outcome gates adopt cards independently of leaked can_adopt", () =
assert.equal(canShowRectificationReadonlyRange(adopted), false);
});
test("parses inference marks from decisionReceipt.inference_state.candidates", () => {
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const result = parseRectificationCandidateResult({
...camelCaseSnapshot,
candidates: [
{ candidateId: CANDIDATE_ID, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
{ candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
],
credible_range: ["04:51", "04:53"],
decisionReceipt: {
...camelCaseSnapshot.decisionReceipt,
inference_state: {
candidates: times.map((clock) => ({
time: clock,
status: clock === "04:51" || clock === "04:53" ? "active" : "eliminated",
})),
},
},
});
assert.ok(result);
assert.ok(result.inferenceMarks);
assert.equal(result.candidates.length, 2);
assert.deepEqual(result.inferenceMarks.map((mark) => mark.time), [...times]);
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:51")?.eliminated, false);
assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:45")?.eliminated, true);
});
test("inference marks are empty when inference_state is absent", () => {
const result = parseRectificationCandidateResult(camelCaseSnapshot);
assert.ok(result);
assert.deepEqual(result.inferenceMarks, []);
});
@@ -28,7 +28,7 @@ import {
parseV9CaseDossier,
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
@@ -186,7 +186,10 @@ test("family collect spoken stem has no year prefix while probe_year stays dated
});
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 只报一件也走既有采集,不插「还有吗」追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
});
test("four scoreable events skip holdout for domains already in the ledger", () => {
@@ -582,13 +585,13 @@ test("career discriminator yes covers career collect without ledger career evide
});
assert.equal(unanswered.next_followup?.domain, "career");
assert.equal(unanswered.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(unanswered.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(unanswered.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
const yes = careerAnswerPlan("yes");
assert.notEqual(yes.next_followup?.domain, "career");
assert.equal(yes.next_followup?.domain, "education");
assert.equal(yes.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(yes.next_followup), USER_COLLECT_QUESTION.education);
assert.equal(spokenFollowupForUser(yes.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.education));
assert.equal(
yes.methods.find((item) => item.method_id === "d10_career")?.status,
"covered",
@@ -600,17 +603,17 @@ test("career discriminator yes covers career collect without ledger career evide
const no = careerAnswerPlan("no");
assert.equal(no.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(no.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(no.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
const unsure = careerAnswerPlan("unsure");
assert.equal(unsure.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(unsure.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(unsure.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
test("career discriminator yes does not invent coverage from a semantic_key prefix", () => {
const plan = careerAnswerPlan("yes", { eventProbes: [] });
assert.equal(plan.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
test("ledger-classified career probe answers do not cover career collect", () => {
@@ -623,7 +626,7 @@ test("ledger-classified career probe answers do not cover career collect", () =>
}],
});
assert.equal(plan.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences, trimEvidenceTurnBody } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
@@ -121,3 +121,13 @@ test("attachQuestionsToTurns leaves body unchanged when the turn has no focus",
assert.equal(turns[0]?.question, null);
});
test("trimEvidenceTurnBody keeps the first sentence and strips value judgments", () => {
const three = trimEvidenceTurnBody("记下了:2016 年 9 月入学。这对校正很有帮助。接下来我们继续。");
assert.equal(three, "记下了:2016 年 9 月入学。");
assert.doesNotMatch(three, /很有帮助|很有价值|很有分量|特别有用/);
const judged = trimEvidenceTurnBody("记下了:2020 年 6 月毕业,特别有用。");
assert.doesNotMatch(judged, /特别有用/);
const two = trimEvidenceTurnBody("记下了:2016 年 9 月入学。\n\n范围收到 04:5005:10。");
assert.equal(two, "记下了:2016 年 9 月入学。\n\n范围收到 04:5005:10。");
});
@@ -33,6 +33,7 @@ import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9
import {
RECTIFICATION_USER_COPY,
USER_COLLECT_QUESTION,
withFirstDatedCollectInvite,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
ATTEMPT_ID,
@@ -462,8 +463,8 @@ function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unkn
});
}
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => {
@@ -486,7 +487,10 @@ test("revision 5 with uncovered relatives asks the dated family collect, not a y
// 旧:year_label 含 2021 → 新:采集口语不带年份前缀,year_label 不进 followup
// 原因:决策 7probe_year 仍锁定 dated 家庭采集,不出无年份 D12 卡
assert.equal(plan.next_followup?.year_label, undefined);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
@@ -1295,9 +1299,12 @@ test("has_new_dated_event continues into the agent after applying the answer", (
answerChoice.indexOf("export async function persistCollectDenialTurn"),
answerChoice.indexOf("export function isStalePreAdoptFocus"),
);
assert.match(persistDenial, /composeCollectSpokenAssistantText\(RECTIFICATION_USER_COPY\.collectDeclinedAck/);
assert.match(persistDenial, /const ack = input\.applied\.ack/);
assert.match(persistDenial, /composeCollectSpokenAssistantText\(ack,/);
assert.match(persistDenial, /linkFocusAskedTurn/);
assert.match(persistDenial, /streamText = hasNextStem \? RECTIFICATION_USER_COPY\.collectDeclinedAck/);
// 旧:streamText 写死 collectDeclinedAck → 新:用 applied.ack,跳过走 collectSkippedAck
// 原因:BUG-605 「记不清」回执与「没有」不同
assert.match(persistDenial, /streamText = hasNextStem \? ack/);
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
assert.match(persistApplied, /command\.deferFollowup !== true/);
assert.equal(shouldContinueAgentForDatedEvent({
@@ -359,7 +359,7 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
assert.match(agentSource, /不得宣称唯一出生分钟/);
assert.doesNotMatch(agentSource, /±2 分钟/);
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
const accounting = fakeAccounting({
...receiptHandlers,
@@ -673,7 +673,10 @@ test("nonterminal invariant 3: answer_choice response waits until incident fallb
// 保留语义:answer_choice 出口仍必须先把家人采集题干写入 focus/turn,再返回 HTTP。
assert.equal(decision.canAdopt, true);
assert.ok(currentQuestion?.prompt);
assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。");
// 旧:家人采集题干原句
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。想到别的也可以一起说。");
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.equal(setFocus.args.p_intent, "collect_method_evidence");
@@ -199,12 +199,12 @@ test("delivery report gives 04:53 D10 as Cancer instead of letting the model inf
assert.match(report.markdown, /04:53 \| .*巨蟹座/);
});
test("skill 10.0.18 forbids computing varga signs from transition times", () => {
test("skill 10.0.19 forbids computing varga signs from transition times", () => {
const skillDir = fileURLToPath(new URL("../../skills/jyotish-birth-time-rectification", import.meta.url));
const skill = readFileSync(`${skillDir}/SKILL.md`, "utf8");
const comparison = readFileSync(`${skillDir}/references/candidate-comparison.md`, "utf8");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.match(skill, /^version: 10\.0\.18$/m);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.match(skill, /^version: 10\.0\.19$/m);
assert.match(skill, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(comparison, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
@@ -13,7 +13,7 @@ import {
parseWindowScan,
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
@@ -1454,9 +1454,9 @@ test("rescore failure does not fail the evidence write", async () => {
assert.ok(result.rescore.error_code);
});
test("public tool surface stays at 14 and new cases bind 10.0.18", () => {
test("public tool surface stays at 14 and new cases bind 10.0.19", () => {
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
const deprecated = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.2",
@@ -2381,7 +2381,10 @@ test("family collect attaches the collection-probe year instead of a yearless D2
// 原因:决策 7,年份只用于计分与去重,不进采集题干
assert.equal(plan.next_followup?.year_label, undefined);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
});
test("already-open yearless family card is abandoned instead of kept as a scoring frame", () => {
@@ -576,8 +576,8 @@ function warnLines(run: () => Promise<unknown> | unknown) {
}).then((result) => ({ result, lines }));
}
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
@@ -335,7 +335,7 @@ test("narrator failure still leaves the choice applied and does not ask the user
scoring: true,
appliedInference: true,
});
assert.match(applied, /已记录你的选择/);
assert.match(applied, /已记录,范围没变/);
assert.doesNotMatch(applied, /请再选一次|重新回答/);
const stopped = composeChoiceNarration({
optionId: "stop",
@@ -213,9 +213,9 @@ test("read-case evidence context keeps day labels and confirm does not rewrite d
assert.equal("p_occurred_from" in confirmCall.args, false);
});
test("new-case skill identity is 10.0.18 and the prompt prefers batch ingest", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.match(skill, /^version: 10\.0\.18$/m);
test("new-case skill identity is 10.0.19 and the prompt prefers batch ingest", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.match(skill, /^version: 10\.0\.19$/m);
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
assert.match(agentSource, /新事件走 rectification-record-evidence-batch/);
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
@@ -184,8 +184,8 @@ const CANDIDATE_IDS = [
"88888888-8888-4888-8888-888888888882",
] as const;
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("nineteen-row ledger opens the training gate with four scoreable domains", () => {
@@ -11,7 +11,10 @@ import {
buildNextUserAction,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
USER_COLLECT_QUESTION,
withFirstDatedCollectInvite,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationQuestionGapState,
@@ -204,6 +207,9 @@ test("family collect spoken stem equals USER_COLLECT_QUESTION.family while probe
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(plan.next_followup?.choice_frame, null);
const spoken = spokenFollowupForUser(plan.next_followup);
assert.equal(spoken, USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 只报一件也走既有采集,不插「还有吗」追问轮
assert.equal(spoken, withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
assert.doesNotMatch(spoken ?? "", /^\d{4} 年前后,/);
});
@@ -296,6 +296,8 @@ test("collect denial persists ack+stem then binds asked_turn_id; stream is ack o
userMessage: "没有",
applied: {
narration: prompt,
ack: RECTIFICATION_USER_COPY.collectDeclinedAck,
closeStatus: "declined",
nextInterviewPersisted: true,
nextChoiceReady: false,
focus: {
@@ -341,6 +343,8 @@ test("collect denial without a next stem still writes a turn and does not invent
userMessage: "没有",
applied: {
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
ack: RECTIFICATION_USER_COPY.collectDeclinedAck,
closeStatus: "declined",
nextInterviewPersisted: false,
nextChoiceReady: false,
focus: null,
@@ -426,8 +426,8 @@ function rpcDossier(decision: DecisionDossier) {
});
}
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
@@ -517,10 +517,11 @@ test("zero-evidence opening persists the server-owned first question", async ()
// 新值: collect:other:collect_method_evidence
// 原因: 开场仍用 domain=otherGENERIC 题干;稳定 id 不得含 unknown
assert.equal(stableFollowupQuestionId(followup), "collect:other:collect_method_evidence");
// 旧:开场 domain=other 后查表落到补问兜底句 → 新:method_coverage + other 且非 retry 用 GENERIC_COLLECT_QUESTION → 保留 开场题由服务端出、带具体例子
// 旧:开场 GENERIC 带「比如哪年上的大学…」→ 新:题干只邀请一两件,领域清单在开场正文
// 原因:BUG-604
assert.equal(spokenFollowupForUser(followup), GENERIC_COLLECT_QUESTION);
assert.doesNotMatch(spokenFollowupForUser(followup) ?? "", /^也可以再/);
assert.match(spokenFollowupForUser(followup) ?? "", /比如/);
assert.match(spokenFollowupForUser(followup) ?? "", /先说你最容易想起的一两件/);
const expected = GENERIC_COLLECT_QUESTION;
const accounting = fakeAccounting({
@@ -606,15 +607,15 @@ test("exhaustion other fallback and other retry keep the after-evidence copy", (
assert.equal(spokenFollowupForUser(retry), null);
});
test("GENERIC_COLLECT_QUESTION keeps concrete opening examples", () => {
test("GENERIC_COLLECT_QUESTION invites one or two memories without listing years", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
assert.match(GENERIC_COLLECT_QUESTION, /比如/);
assert.ok(
[...GENERIC_COLLECT_QUESTION.matchAll(/上的大学|换的工作|搬的家/g)].length >= 2,
GENERIC_COLLECT_QUESTION,
);
// 旧:「…开始就好——比如哪年上的大学、哪年换的工作…」→ 新:加「哪年搬的家」→ 保留 开场题必须带具体例子、由服务端出
assert.match(source, /GENERIC_COLLECT_QUESTION = "你最容易想起的一件事开始就好/);
// 旧:从你最容易想起来的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家
// 新:先说你最容易想起的一两件,年月大概就行。领域清单在开场正文。
// 原因:BUG-604 决策 1,题干不再列年份例子
assert.equal(GENERIC_COLLECT_QUESTION, "先说你最容易想起的一两件,年月大概就行。");
assert.doesNotMatch(GENERIC_COLLECT_QUESTION, /比如/);
assert.doesNotMatch(GENERIC_COLLECT_QUESTION, /(?:19|20)\d{2}/);
assert.match(source, /GENERIC_COLLECT_QUESTION = "先说你最容易想起的一两件,年月大概就行。"/);
});
test("degraded spoken collect does not keep discriminator identity or block a later card", async () => {
@@ -10,8 +10,15 @@ import {
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 {
declinedCollectDomains,
nextDatedCollectFollowup,
reverseVerifyRemainingForAdopt,
spokenFollowupForUser,
spokenCollectFallbackFollowup,
type MethodFollowup,
type MethodFollowupEvidence,
} 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,
@@ -28,6 +35,8 @@ import {
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";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { isCollectDeclineUtterance, isCollectSkipUtterance } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
const RELATIONSHIP_PROMPT = USER_COLLECT_QUESTION.relationship;
@@ -85,8 +94,8 @@ function collectPersistResult(overrides: {
};
}
test("skill version is 10.0.18 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("cases current_question remains the submit contract, not a visual slot", () => {
@@ -107,6 +116,19 @@ test("cases current_question remains the submit contract, not a visual slot", ()
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
assert.doesNotMatch(wrap, /rectification-collect-stop/);
assert.doesNotMatch(wrap, /CHOICE_STOP_LABEL/);
// 旧:输入框上方无按钮(先这样已删)
// 新:collect_spoken 且未 busy 时两个 44px 次要按钮「没有」「记不清」
// 原因:BUG-605;不得复用 rectification-step-state
assert.match(wrap, /rectification-collect-replies/);
assert.match(wrap, /rectification-collect-reply/);
assert.match(wrap, /send\("message", "没有"\)/);
assert.match(wrap, /send\("message", "记不清"\)/);
assert.match(wrap, />[\s\n]*没有[\s\n]*</);
assert.match(wrap, />[\s\n]*记不清[\s\n]*</);
assert.doesNotMatch(wrap, /rectification-step-state/);
assert.match(chat, /collectSpokenPrompt && !busy && !readonly && regeneratingMessageKey === null/);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(styles, /\.rectification-collect-reply \{[\s\S]*min-height:\s*44px/);
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
@@ -403,3 +425,103 @@ test("agent route keeps question ownership in the server Case projection", () =>
assert.match(agentRun, /askedTurnId: turnId/);
assert.doesNotMatch(agentRun, /当前可询问范围/);
});
test("没有 and 记不清 short-circuit collect_spoken without a model run", () => {
assert.equal(isCollectDeclineUtterance("没有"), true);
assert.equal(isCollectDeclineUtterance("没有。"), true);
assert.equal(isCollectSkipUtterance("记不清"), true);
assert.equal(isCollectSkipUtterance("记不清!"), true);
assert.equal(isCollectDeclineUtterance("记不清"), false);
assert.equal(isCollectSkipUtterance("没有"), false);
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const collectStart = route.lastIndexOf("} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema))");
const collectBranch = route.slice(collectStart);
const collectFocus = collectBranch.slice(0, collectBranch.indexOf("classifyRectificationTurnIntent"));
assert.match(collectFocus, /isCollectDeclineUtterance/);
assert.match(collectFocus, /isCollectSkipUtterance/);
assert.match(collectFocus, /status: isCollectSkipUtterance\(userMessage\) \? "skipped" : "declined"/);
assert.match(collectFocus, /persistCollectDenialTurn/);
assert.doesNotMatch(collectFocus, /runV9AgentTurn/);
});
test("skipped collect domains are not re-asked this session; reverse-verify may still touch them", () => {
const skippedFamily = [{
target_domain: "family",
status: "skipped",
intent: "collect_method_evidence",
}];
const declined = declinedCollectDomains(skippedFamily);
assert.equal(declined.has("family"), true);
const next = nextDatedCollectFollowup([], declined);
assert.notEqual(next?.domain, "family");
const familyProbe: DiscriminatingEventProbe = {
year: 2018,
year_label: "2018 年前后",
domain: "family",
event_family: "家里的大事",
source: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2018 年。",
role: "distinguish",
information_gain: 1.1,
semantic_key: "family.2018.dasha_boundary",
candidate_split_hash: "family.2018.dasha_boundary",
candidate_ids: ["05:00", "05:20"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
],
choice_kind: "existence",
};
const remaining = reverseVerifyRemainingForAdopt({
eventProbes: [familyProbe],
evidence: [],
declinedTopics: skippedFamily,
});
assert.equal(remaining.some((probe) => probe.domain === "family"), true);
});
test("single confirmed domain opening goes to dated collect without a chase-more turn", () => {
const evidence: MethodFollowupEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
];
const next = nextDatedCollectFollowup(evidence, new Set());
assert.ok(next);
assert.notEqual(next.domain, "education");
assert.equal(next.domain, "family");
const spoken = spokenFollowupForUser(next);
assert.ok(spoken);
assert.match(spoken, /家里/);
assert.match(spoken, /想到别的也可以一起说/);
assert.doesNotMatch(spoken, /还有吗|别的吗|还能想起别的吗/);
assert.doesNotMatch(spoken, /先说你最容易想起的一两件/);
const later = nextDatedCollectFollowup(evidence, new Set(), new Set(), { askedDatedCollect: true });
assert.equal(later?.domain, "family");
assert.doesNotMatch(spokenFollowupForUser(later) ?? "", /想到别的也可以一起说/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /recordV10EvidenceBatch/);
assert.match(tools, /result\.acceptedCount > 0[\s\S]*autoRescoreAfterEvidenceChange/);
const copy = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.doesNotMatch(copy, /还有吗|还能想起别的吗/);
assert.doesNotMatch(agent, /还有吗|还能想起别的吗/);
assert.doesNotMatch(agentRun, /还有吗|还能想起别的吗/);
});
test("three confirmed domains from one message are not re-asked by dated collect", () => {
const evidence: MethodFollowupEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null },
{ status: "confirmed", domain: "relocation", datePrecision: "year", occurredFrom: "2023-01-01", occurredTo: null },
];
const next = nextDatedCollectFollowup(evidence, new Set());
assert.notEqual(next?.domain, "education");
assert.notEqual(next?.domain, "career");
assert.notEqual(next?.domain, "relocation");
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /recordV10EvidenceBatch/);
assert.match(tools, /result\.acceptedCount > 0[\s\S]*autoRescoreAfterEvidenceChange/);
});
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import {
buildRectificationTimeline,
formatClockMinutes,
@@ -86,7 +87,8 @@ test("the band is placed against the axis and never escapes it", () => {
assert.equal(Math.round(view.bandWidthPercent), 7);
assert.ok(view.bandStartPercent + view.bandWidthPercent <= 100);
assert.equal(view.rangeLabel, "05:0705:09");
assert.equal(view.widthLabel, "2 分钟");
// Inclusive of both ends: 05:07, 05:08, 05:09. Exclusive difference was "2 分钟".
assert.equal(view.widthLabel, "3 分钟");
// A range reported wider than the window is clamped rather than overflowing.
const overflowing = buildRectificationTimeline({
@@ -100,7 +102,33 @@ test("the band is placed against the axis and never escapes it", () => {
assert.equal(overflowing.bandWidthPercent, 100);
});
test("candidate marks are binary and the range boundary counts as inside", () => {
test("candidate marks follow inference status, not band position", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:07", "05:08", "05:09"],
inferenceMarks: [
{ time: "05:06", eliminated: true },
{ time: "05:07", eliminated: false },
{ time: "05:08", eliminated: true },
{ time: "05:09", eliminated: false },
{ time: "05:10", eliminated: true },
],
stage: "minute",
});
assert.ok(view);
// 05:08 sits inside the band but is eliminated → hollow. Position vs band is not the judge.
assert.deepEqual(view.marks.map((mark) => [mark.key, mark.state]), [
["m306", "out"],
["m307", "in"],
["m308", "out"],
["m309", "in"],
["m310", "out"],
]);
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
});
test("without inference marks every engine candidate is solid", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
@@ -108,12 +136,7 @@ test("candidate marks are binary and the range boundary counts as inside", () =>
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "in", "in", "out"]);
// Closed interval: both ends of the range are in, not on the fence.
assert.equal(view.marks[1]?.state, "in");
assert.equal(view.marks[3]?.state, "in");
// No third state exists to grade confidence with.
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in", "in", "in"]);
});
test("marks outside the window are dropped and duplicates collapse", () => {
@@ -135,11 +158,11 @@ test("block scan draws the window as the range and no minute marks", () => {
stage: "block_scan",
});
assert.ok(view);
// Saying "23 小时 59 分" here is the truth: nothing has been ruled out yet.
// Inclusive 00:0023:59 is a full day. Exclusive difference was "23 小时 59 分".
assert.equal(view.bandStartPercent, 0);
assert.equal(view.bandWidthPercent, 100);
assert.equal(view.rangeLabel, "00:0023:59");
assert.equal(view.widthLabel, "23 小时 59 分");
assert.equal(view.widthLabel, "24 小时");
assert.deepEqual(view.marks, []);
});
@@ -176,7 +199,8 @@ test("widening the window rescales the axis and keeps the band inside it", () =>
// The widened range still fits: an axis pinned to the opening window would not hold it.
assert.ok(after.bandStartPercent >= 0);
assert.ok(after.bandStartPercent + after.bandWidthPercent <= 100);
assert.equal(after.widthLabel, "24 分钟");
// 04:5605:20 inclusive is 25 minutes. Exclusive difference was "24 分钟".
assert.equal(after.widthLabel, "25 分钟");
});
test("a window that crosses midnight stays monotonic", () => {
@@ -191,11 +215,126 @@ test("a window that crosses midnight stays monotonic", () => {
assert.equal(view.axisStartLabel, "23:00");
assert.equal(view.axisEndLabel, "03:59");
assert.equal(view.rangeLabel, "00:3001:30");
assert.equal(view.widthLabel, "1 小时");
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "out"]);
// Inclusive 00:3001:30 is 61 minutes. Exclusive difference was "1 小时".
assert.equal(view.widthLabel, "1 小时 1 分");
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in"]);
assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100));
});
test("widthLabel counts both clock ends, matching the delivery-report inclusive minute shape", () => {
const label = (
searchWindow: readonly [string, string],
credibleRange: readonly [string, string] | null,
) => buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: [],
stage: credibleRange ? "minute" : "block_scan",
})?.widthLabel;
assert.equal(label(["04:45", "05:15"], ["04:51", "04:59"]), "9 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:09"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["04:51", "04:53"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:07"]), "1 分钟");
assert.equal(label(["00:00", "23:59"], null), "24 小时");
});
test("eliminated inference minutes stay as hollow dots and keep stable keys", () => {
const searchWindow = ["04:45", "05:15"] as const;
const credibleRange = ["04:51", "04:53"] as const;
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const marksFor = (active: ReadonlySet<string>) => times.map((time) => ({
time,
eliminated: !active.has(time),
}));
const first = buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: ["04:51", "04:53"],
inferenceMarks: marksFor(new Set(["04:51", "04:53"])),
stage: "minute",
});
assert.ok(first);
assert.equal(first.marks.length, 9);
assert.equal(first.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(first.marks.filter((mark) => mark.state === "in").length, 2);
const firstKeys = first.marks.map((mark) => mark.key);
const second = buildRectificationTimeline({
searchWindow,
credibleRange: ["04:53", "04:53"],
candidateTimes: ["04:53"],
inferenceMarks: marksFor(new Set(["04:53"])),
stage: "minute",
});
assert.ok(second);
assert.equal(second.marks.length, 9);
assert.equal(second.marks.filter((mark) => mark.state === "out").length, 8);
assert.equal(second.marks.filter((mark) => mark.state === "in").length, 1);
assert.deepEqual(second.marks.map((mark) => mark.key), firstKeys);
});
test("timeline chat wiring reads parsed inferenceMarks and never the raw receipt", () => {
const timelineBlock = chat.slice(
chat.indexOf("const timelineView = buildRectificationTimeline"),
chat.indexOf("const persistedOfferKey"),
);
assert.match(timelineBlock, /inferenceMarks:\s*candidateResult\?\.inferenceMarks/);
assert.doesNotMatch(timelineBlock, /decisionReceipt|inference_state/);
});
test("parsed inference_state marks reach the timeline even when engine candidates are only the active minutes", () => {
const activeId = "88888888-8888-4888-8888-888888888881";
const secondId = "88888888-8888-4888-8888-888888888882";
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const result = parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: activeId, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
{ candidateId: secondId, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
],
overallConfidence: "low",
selectionAllowed: false,
canAdopt: false,
confirmationAllowed: false,
representativeTime: "04:53",
selectedTime: null,
selectionKind: null,
credibleRange: ["04:51", "04:53"],
decisionReceipt: {
inference_state: {
candidates: times.map((time) => ({
time,
status: time === "04:51" || time === "04:53" ? "active" : "eliminated",
})),
},
},
});
assert.ok(result);
assert.ok(result.inferenceMarks);
assert.equal(result.candidates.length, 2);
assert.equal(result.inferenceMarks.length, 9);
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: result.credibleRange,
candidateTimes: result.candidates.map((candidate) => candidate.time),
inferenceMarks: result.inferenceMarks,
stage: "minute",
});
assert.ok(view);
assert.equal(view.marks.length, 9);
assert.equal(view.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(view.marks.filter((mark) => mark.state === "in").length, 2);
assert.equal(view.widthLabel, "3 分钟");
});
test("duration reads as clock language, not raw minutes", () => {
assert.equal(timelineDurationLabel(2), "2 分钟");
assert.equal(timelineDurationLabel(59), "59 分钟");
+75 -9
View File
@@ -19,11 +19,17 @@ import {
createRectificationV9ReadOnlyTools,
createRectificationV9Tools,
} from "../src/mastra/rectification-v9-tools.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { buildOpeningBrief, runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { parseV9CaseDossier, persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
GENERIC_COLLECT_QUESTION,
OPENING_COLLECT_DOMAINS,
RECTIFICATION_USER_COPY,
isAcceptableOpeningBody,
openingSpokenBody,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
@@ -68,7 +74,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /思考用简体中文写在思维链/);
assert.match(prompt, /对用户说的话必须自己写在正文里/);
assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
assert.match(prompt, /每轮正文 2-4 句/);
// 旧:每轮正文 2-4 句 → 新:证据轮正文只写一句复述 → BUG-606 决策 3
assert.match(prompt, /证据轮正文只写一句复述/);
assert.match(prompt, /「先这样」由服务器/);
assert.match(prompt, /collection_progress/);
assert.match(prompt, /不得写「范围在收窄」/);
@@ -88,11 +95,11 @@ test("system prompt carries only high-priority boundaries, never the method copy
test("agent pins the dedicated rectification skill and its fixed version", () => {
assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(basename(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_SKILL_NAME);
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.18"));
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.19"));
assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("step budgets are bounded per action with a hard ceiling", () => {
@@ -239,7 +246,13 @@ test("agent receives the exact server-owned case id for tool calls", async () =>
const openingPrompt = JSON.stringify(observedMessages);
assert.match(openingPrompt, new RegExp(CASE_ID));
assert.match(openingPrompt, /服务端 opening brief/);
assert.match(openingPrompt, /不要要求一次说完/);
// 旧:不要要求一次说完、不要举大学/工作/搬家的例子
// 新:窗口 + 六类领域 + 一条消息可以报多件
// 原因:BUG-604 决策 1,重算按轮计费
assert.match(openingPrompt, /一条消息可以报多件/);
assert.match(openingPrompt, /升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤/);
assert.match(openingPrompt, /04:5005:10|当前搜索窗口/);
assert.match(openingPrompt, /intake 声明的不确定档/);
assert.match(openingPrompt, /spokenPrompt 写出当前采集题/);
assert.match(openingPrompt, /不要提问/);
assert.doesNotMatch(openingPrompt, /接在正文末尾并写入聊天历史/);
@@ -392,13 +405,17 @@ test("collect_spoken stem is persisted on the same turn via asked_turn_id, not a
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, greeting);
// 旧:模型一句招呼原样落库 → 新:开场正文必须过六类领域验收,不合格则换成模板
// 原因:BUG-604 开场 ≤4 句、≥5 类、不含年份
const spoken = openingSpokenBody(["04:50", "05:10"]);
assert.equal(isAcceptableOpeningBody(result.answerText), true);
assert.equal(result.answerText, spoken);
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
assert.notEqual(replaced?.text, composeCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION));
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(finalize?.args.p_assistant_message, greeting);
assert.equal(finalize?.args.p_assistant_message, spoken);
assert.equal(
accounting.calls.some((call) => (
call.fn === "set_agentic_rectification_conversation_focus"
@@ -785,3 +802,52 @@ test("agent-run uses collectHandoff when the owned-focus body is only a question
assert.equal(result.ok, true);
assert.equal(result.answerText, RECTIFICATION_USER_COPY.collectHandoff);
});
test("buildOpeningBrief names the search window, intake source, and six domains", () => {
const dossier = parseV9CaseDossier(dossierFixture({
candidateRange: { start_time: "04:45", end_time: "05:15" },
evidence: [],
evidenceCount: 0,
}));
assert.ok(dossier);
const brief = buildOpeningBrief(dossier);
assert.match(brief, /当前搜索窗口:04:4505:15/);
assert.match(brief, /intake 声明的不确定档/);
for (const domain of OPENING_COLLECT_DOMAINS) {
assert.match(brief, new RegExp(domain));
}
assert.match(brief, /先说你最容易想起的一两件,年月大概就行/);
assert.doesNotMatch(brief, /不要要求一次说完/);
assert.doesNotMatch(brief, /不要举大学、工作、搬家的例子/);
assert.doesNotMatch(brief, /(?:19|20)\d{2}/);
});
test("evidence persist keeps the first sentence when the model writes three", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
action: "evidence",
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", {
text: "记下了:2016 年 9 月入学、2020 年 6 月毕业。这对校正很有帮助。接下来我们继续。",
}),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(result.answerText, /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/);
assert.doesNotMatch(result.answerText, /很有帮助|很有价值|很有分量|特别有用/);
assert.doesNotMatch(result.answerText, /接下来我们继续/);
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.match(String(finalize?.args.p_assistant_message ?? ""), /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/);
assert.doesNotMatch(String(finalize?.args.p_assistant_message ?? ""), /很有帮助|特别有用/);
});
@@ -95,9 +95,9 @@ test("terminal transitions are one-way and evidence writes stop at terminal", ()
test("the active rectification skill pins the v10 identity and lives in the right directory", () => {
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.18");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
assert.match(skill, /^version: 10\.0\.18$/m);
assert.match(skill, /^version: 10\.0\.19$/m);
assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/);
for (const reference of references) {
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
@@ -209,7 +209,7 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
session_id: SESSION_ID,
status: "draft",
should_start_opening: true,
skill_version: "10.0.18",
skill_version: "10.0.19",
};
}
return null;
@@ -247,11 +247,11 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
});
assert.equal(response.disposition, "created");
assert.equal(response.shouldStartOpening, true);
assert.equal(response.skillVersion, "10.0.18");
assert.equal(response.skillVersion, "10.0.19");
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
assert.ok(openCall);
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
assert.equal(openCall.args.p_skill_version, "10.0.18");
assert.equal(openCall.args.p_skill_version, "10.0.19");
assert.equal(openCall.args.p_user_id, "user-1");
// The server derives the baseline; the request never carries it from the browser.
assert.equal("birth_date" in openCall.args, false);
+15 -10
View File
@@ -1404,11 +1404,14 @@ test("Chinese interview planning stays on reasoning-delta; the spoken answer is
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, spoken);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: spoken }],
);
// 旧:模型正文含追问原样落库 → 新:证据轮超过两句只留首句
// 原因:BUG-606 服务端裁剪,不靠提示词
const recap = "职业类型已经记下。";
assert.equal(result.answerText, recap);
const spokenDeltas = emitted.filter((event) => event.type === "answer.delta");
assert.equal(spokenDeltas[0]?.text, spoken);
assert.equal(spokenDeltas.at(-1)?.text, recap);
assert.equal((spokenDeltas.at(-1) as { replace?: boolean } | undefined)?.replace, true);
const publicText = JSON.stringify(emitted);
assert.doesNotMatch(publicText, /occupation_note/);
assert.doesNotMatch(publicText, /让我继续访谈/);
@@ -1575,11 +1578,13 @@ test("model terminal text-delta is the reply even when Case evidence is availabl
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, spoken);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: spoken }],
);
// 旧:模型正文含入职追问原样落库 → 新:证据轮超过两句只留首句
// 原因:BUG-606 服务端裁剪
const recap = "职业已经记下。";
assert.equal(result.answerText, recap);
const spokenDeltas = emitted.filter((event) => event.type === "answer.delta");
assert.equal(spokenDeltas[0]?.text, spoken);
assert.equal(spokenDeltas.at(-1)?.text, recap);
assert.doesNotMatch(result.answerText, /已经记下:/);
assert.doesNotMatch(result.answerText, /2016年9月离家去北京开始工作/);
});
@@ -379,3 +379,21 @@ test("choice option focus ring stays inside the button and does not stack on the
assert.doesNotMatch(card, /firstChoiceRef/);
assert.doesNotMatch(card, /\.focus\(/);
});
test("vargaSentence stays in expanded activity rows, not the spoken bubble", () => {
const content = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
const activity = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const timeline = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
// 旧:ChatMessageContent 渲染 message-varga-sentence
// 新:气泡正文不再带方法句;活动记录展开区保留
// 原因:BUG-606 决策 3(a)
assert.doesNotMatch(content, /vargaSentence/);
assert.doesNotMatch(content, /message-varga-sentence/);
assert.match(activity, /vargaSentence/);
assert.match(activity, /VargaTraceStep/);
assert.match(timeline, /vargaSentence/);
assert.match(timeline, /inert=\{open \? undefined : true\}/);
assert.match(row, /<ConsultationRunTimeline[\s\S]*vargaSentence=\{vargaSentence\}/);
assert.doesNotMatch(row, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
});
@@ -417,7 +417,9 @@ test("agent instructions do not assert that the next question is already on scre
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(agent, /正文不得断言界面当前状态/);
assert.match(agent, /接下来我们继续/);
// 旧:用「接下来我们继续」垫句 → 新:题干作为同一条消息下一段出现,不再垫过渡句
// 原因:BUG-606 每轮一句,开场/证据轮不再写「接下来我们继续」
assert.match(agent, /题干会作为同一条消息的下一段自动出现/);
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.match(chat, /await loadCaseSnapshot\(\)/);
assert.match(chat, /mergeTurnQuestions/);
+2 -2
View File
@@ -85,8 +85,8 @@ test("checked-in registry verifies hashed product packages and leaves consult on
[
{
name: "jyotish-birth-time-rectification",
version: "10.0.18",
sha256: "ad209a7824d7eb65f35efd7d6245e9a3752a8b32972de0fbafb751691e86a1ad",
version: "10.0.19",
sha256: "a68885d3cf2110ee456bff65210c60e6bef4da4ea45e2a790a5462fd6d7307c8",
},
{
name: "jyotish-personal-report",