fix(rectification): persist the next interview after a choice tap
Independent Staging Quality Gate / validate (push) Successful in 13m56s
Independent Staging Quality Gate / publish (push) Successful in 16m46s

Closing a discriminator used to leave GET without a card after refresh.
Write the next dated question in the same request, skip childhood career
and move probes, and do not continue a read-only turn when that question
is already persisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-28 16:30:09 +08:00
co-authored by Cursor
parent 8c75ebb196
commit 68f0759eef
26 changed files with 2455 additions and 311 deletions
@@ -101,6 +101,7 @@ test("answer deltas preserve a still-running server activity", () => {
);
assert.match(deltaBranch, /state: raw\.trim\(\) \? "streaming" : "thinking"/);
assert.match(deltaBranch, /text: raw,/);
assert.match(deltaBranch, /event\.replace === true \? event\.text : raw \+ event\.text/);
assert.doesNotMatch(deltaBranch, /settled\.spoken/);
assert.match(deltaBranch, /正在组织回答/);
assert.doesNotMatch(deltaBranch, /activeActivity:\s*undefined/);
@@ -250,7 +250,10 @@ test("usage completes or releases without hiding settlement failures", () => {
assert.match(run, /activity.changed/);
assert.match(run, /if \(!answerText\.trim\(\)\) \{[\s\S]*composeRectificationTurnNarration/);
assert.match(run, /bindSpokenToOpenQuestion/);
assert.match(run, /CHOICE_CARD_CONTINUATION_ACK/);
assert.match(run, /openQuestionPromptFromToolResult/);
assert.doesNotMatch(run, /heldSpoken/);
assert.match(run, /replace: true/);
const narration = readFileSync(
new URL("../src/lib/rectification-agentic/v9/turn-narration.ts", import.meta.url),
"utf8",
@@ -293,9 +296,9 @@ test("Agentic rectification follows the conversation tail only while the reader
assert.match(chat, /choiceCardsOpen/);
assert.match(
chat,
/useLayoutEffect\(\(\) => \{\s*choiceCardsOpen\.current = showChoiceCards;\s*updateFollowState\(\);\s*\}, \[showChoiceCards, updateFollowState\]\)/,
/useLayoutEffect\(\(\) => \{\s*choiceCardsOpen\.current = showLiveChoiceCard;\s*updateFollowState\(\);\s*\}, \[showLiveChoiceCard, updateFollowState\]\)/,
);
assert.doesNotMatch(chat, /choiceCardsOpen\.current = showChoiceCards;\s*useLayoutEffect/);
assert.doesNotMatch(chat, /choiceCardsOpen\.current = showLiveChoiceCard;\s*useLayoutEffect/);
assert.match(chat, /updateFollowState/);
assert.match(chat, /\}, \[busy, candidateResult, choiceCard, error, messages, savedTime, followLatestContent\]\);/);
const jumpLatestRule = styles.match(/\.rectification-jump-latest \{[^}]+\}/)?.[0] ?? "";
@@ -539,26 +542,31 @@ test("time-selection cards appear under the latest settled agent bubble only aft
assert.match(chat, /candidateResult\?\.selectionAllowed/);
assert.match(chat, /turnOfferedSelection/);
assert.match(chat, /rectification-offer-candidates/);
assert.match(chat, /showChoiceCards = Boolean\(\s*choiceCard[\s\S]*!offeredThisTurn[\s\S]*!busy/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*offeredSelectionOnce[\s\S]*!showChoiceCards[\s\S]*!busy/);
assert.match(chat, /showLiveChoiceCard = Boolean\(\s*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*offeredSelectionOnce[\s\S]*!showLiveChoiceCard[\s\S]*!busy/);
assert.match(
chat,
/selectionCardMessageKey = showSelectionCards && latestSettledAssistant\s*\? latestSettledAssistant\.renderKey\s*: undefined/,
);
assert.match(messageLoop, /showSelectionCards && message\.renderKey === selectionCardMessageKey/);
assert.match(messageLoop, /<RectificationChoiceCard/);
assert.match(messageLoop, /key=\{`\$\{choiceCard\.question_id\}:\$\{choiceNonce\}`\}/);
assert.match(messageLoop, /choiceAttachment/);
assert.match(messageLoop, /choiceNonce/);
assert.match(messageLoop, /answered:\$\{settledChoice\.selectedKey\}/);
assert.doesNotMatch(choiceCardComponent, /setSelectedKey\(""\)/);
assert.match(chat, /showChoiceCards = Boolean\(/);
assert.match(chat, /choiceCardUserMessage/);
assert.match(choiceCardComponent, /selectedKey/);
assert.match(choiceCardComponent, /is-answered/);
assert.match(chat, /showLiveChoiceCard = Boolean\(/);
assert.match(chat, /isStructuredChoiceUserText/);
assert.match(chat, /submitStructuredChoice\(CHOICE_ACTION/);
assert.match(chat, /submitStructuredChoice\(STOP_ACTION/);
assert.match(route, /answer_choice/);
assert.match(route, /stop_and_review/);
assert.match(route, /applyRectificationChoice\(accounting/);
assert.doesNotMatch(chat, /v9-choice-user-/);
assert.doesNotMatch(chat, /send\("message", choiceCardUserMessage/);
assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/);
assert.match(chat, /CHOICE_STOP_MESSAGE/);
assert.doesNotMatch(chat, /choiceCardUserMessage/);
assert.match(caseRoute, /choice_card: choiceCardFromCaseDossier/);
assert.match(caseRoute, /overlayPublicDecision/);
assert.match(caseRoute, /interview: publicDecisionFields/);
@@ -580,7 +588,7 @@ test("does not parse suggestions from an incomplete run", () => {
});
test("live answer.delta is the model reply, not a spoken-thinking split", () => {
assert.match(chat, /raw \+= event\.text/);
assert.match(chat, /raw = event\.replace === true \? event\.text : raw \+ event\.text/);
assert.match(chat, /text: raw,/);
assert.doesNotMatch(chat, /settleRectificationSpokenAndThinking/);
assert.doesNotMatch(chat, /text: settled\.spoken/);
@@ -672,13 +680,20 @@ test("choice cards render the persisted prompt as the visible question stem", ()
assert.doesNotMatch(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
});
test("choice card answers and stop share one stacked primary list", () => {
assert.match(choiceCardComponent, /birth-time-primary-choices/);
assert.match(choiceCardComponent, /props\.card\.options\.map/);
assert.doesNotMatch(choiceCardComponent, /birth-time-special-choices/);
assert.doesNotMatch(choiceCardComponent, /is-secondary/);
});
test("adopted time offers a consultation handoff without unique-minute copy", () => {
assert.match(chat, /用这个时间看盘/);
assert.match(chat, /onStartConsultation/);
assert.match(board, /换升时刻/);
assert.match(board, /经历与大运对照/);
assert.match(chat, /<RectificationChoiceCard/);
assert.match(chat, /choiceCardUserMessage/);
assert.match(chat, /choiceAttachment/);
assert.match(page, /startConsultationAfterRectification/);
assert.match(page, /createSession\(modelCatalog\.defaultModelId\)/);
assert.match(agent, /start_consultation/);
@@ -3,10 +3,12 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import {
CHOICE_ACTION,
STOP_ACTION,
composeChoiceNarration,
isStructuredChoiceUserText,
quoteIsFromAssistantQuestion,
shouldContinueAfterStructuredChoice,
userVisibleChoiceLine,
@@ -22,12 +24,14 @@ import { buildInferenceState } from "../src/lib/rectification-agentic/core/build
import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
EVIDENCE_ID,
FOCUS_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
@@ -36,6 +40,116 @@ import {
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const QUESTION_ID = "question-1";
const NEXT_FOCUS_ID = "acacacac-acac-4cac-8cac-acacacacacac";
const EDUCATION_ID = "44444444-4444-4444-8444-444444444445";
const RELATIONSHIP_ID = "44444444-4444-4444-8444-444444444446";
const CAREER_EXIT_ID = "44444444-4444-4444-8444-444444444447";
const RELATIONSHIP_END_ID = "44444444-4444-4444-8444-444444444448";
const STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const RELOCATION_2015_PROBE = {
id: "p-reloc-2015",
semantic_key: "relocation.2015.05.dasha_boundary",
candidate_split_hash: "reloc-2015-split",
domain: "relocation",
year: 2015,
month: 5,
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.87,
source: "dasha_boundary",
};
const EDUCATION_2014_PROBE = {
id: "p-edu-2014",
semantic_key: "education.2014",
candidate_split_hash: "edu-2014-split",
domain: "education",
year: 2014,
question: "2014 年前后有没有升学、转学或换学习环境?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.64,
source: "dasha_activation",
};
const FAMILY_2021_COLLECT = {
year: 2021,
year_label: "2021 年前后",
domain: "family",
event_family: "家人结婚、添丁或住院",
source: "age_band",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family;语义目标是家人结婚、添丁或住院。",
role: "collect",
phase: "evidence_collection",
information_gain: 0,
semantic_key: "family.2021",
candidate_split_hash: "family:2021",
candidate_ids: [],
expected_outcomes: [],
style_options: STYLE_OPTIONS,
choice_kind: "existence",
};
const YEARLESS_D24_PROBE = {
id: "contrast:varga.d24.05:00/05:10",
semantic_key: "varga.d24.05:00/05:10",
candidate_split_hash: "varga.d24.05:00/05:10",
domain: "education",
year: 0,
question: "引擎给出的区分机会绑定 D24。",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
],
information_gain: 2.5,
source: "varga_contrast",
};
function eventProbeFromConflict(probe: typeof RELOCATION_2015_PROBE | typeof EDUCATION_2014_PROBE) {
return {
year: probe.year,
year_label: `${probe.year} 年前后`,
...("month" in probe && probe.month ? { month: probe.month, year_label: `${probe.year}${probe.month} 月前后` } : {}),
domain: probe.domain,
event_family: probe.domain === "education"
? "升学、转学或换学习环境"
: "搬家或长期住到外地",
source: probe.source,
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
phase: "candidate_discriminator",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
style_options: STYLE_OPTIONS,
choice_kind: "existence",
};
}
function inferenceState() {
return buildInferenceState({
@@ -100,6 +214,219 @@ function choiceDossier() {
});
}
function twoProbeInference() {
return buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 16 },
{ id: "05:10", time: "05:10", relative_support: 14 },
],
events: [
{ id: "e1", domain: "career", year: 2020, precision: "month" },
{ id: "e2", domain: "career", year: 2024, precision: "day" },
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
{ id: "e4", domain: "career", year: 2026, precision: "day" },
{ id: "e5", domain: "relationship", year: 2024, precision: "day" },
],
probes: [RELOCATION_2015_PROBE, EDUCATION_2014_PROBE],
});
}
function fourEventRows() {
return [
{
id: EVIDENCE_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2020-04-01",
occurred_to: "2020-04-01",
date_precision: "month",
summary: "2020 年 4 月开始实习",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-28T07:35:45.000Z",
},
{
id: EDUCATION_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2024-04-07",
occurred_to: null,
date_precision: "day",
summary: "2024 年 4 月 7 日正式入职",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-28T07:36:35.000Z",
},
{
id: RELATIONSHIP_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_start",
domain: "relationship",
occurred_from: "2024-05-01",
occurred_to: "2024-05-01",
date_precision: "month",
summary: "2024 年 5 月认识一位女生",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-28T07:37:02.000Z",
},
{
id: CAREER_EXIT_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_exit",
domain: "career",
occurred_from: "2026-08-11",
occurred_to: null,
date_precision: "day",
summary: "2026 年 8 月 11 日离职",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-28T07:36:35.000Z",
},
{
id: RELATIONSHIP_END_ID,
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_end",
domain: "relationship",
occurred_from: "2024-08-08",
occurred_to: "2024-08-08",
date_precision: "day",
summary: "2024 年 8 月 8 日与对方分手",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-28T07:37:02.000Z",
},
];
}
function twoProbeDossier() {
const inference = twoProbeInference();
const snapshot = candidateSnapshotFixture({
decisionReceipt: {
inference_state: inference,
discriminating_event_probes: [
eventProbeFromConflict(RELOCATION_2015_PROBE),
eventProbeFromConflict(EDUCATION_2014_PROBE),
],
},
});
return dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "distinguish_candidates",
targetDomain: "relocation",
targetKind: "home_change",
expectedAnswerSchema: {
choice: {
prompt: "2015 年 5 月前后,有没有搬家或长期住到外地?",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
probe_id: RELOCATION_2015_PROBE.id,
semantic_key: RELOCATION_2015_PROBE.semantic_key,
candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash,
},
}),
}),
turns: [{
id: TURN_ID,
role: "assistant",
text: "接下来有一个问题需要您点选一下,麻烦看一下下方选项。",
status: "completed",
created_at: "2026-08-28T07:36:54.000Z",
completed_at: "2026-08-28T07:37:34.000Z",
}],
});
}
function familyCollectInference() {
return buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 16 },
{ id: "05:10", time: "05:10", relative_support: 14 },
],
events: [
{ id: "e1", domain: "career", year: 2020, precision: "month" },
{ id: "e2", domain: "career", year: 2024, precision: "day" },
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
{ id: "e4", domain: "career", year: 2026, precision: "day" },
{ id: "e5", domain: "relationship", year: 2024, precision: "day" },
],
probes: [RELOCATION_2015_PROBE, YEARLESS_D24_PROBE],
});
}
function familyCollectDossier() {
const inference = familyCollectInference();
const snapshot = candidateSnapshotFixture({
decisionReceipt: {
inference_state: inference,
discriminating_event_probes: [eventProbeFromConflict(RELOCATION_2015_PROBE)],
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
});
return dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "distinguish_candidates",
targetDomain: "relocation",
targetKind: "home_change",
expectedAnswerSchema: {
choice: {
prompt: "2015 年 5 月前后,有没有搬家或长期住到外地?",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
probe_id: RELOCATION_2015_PROBE.id,
semantic_key: RELOCATION_2015_PROBE.semantic_key,
},
}),
}),
});
}
function persistChoiceAccounting(
dossier: ReturnType<typeof twoProbeDossier>,
extra: Parameters<typeof fakeAccounting>[0] = {},
) {
return choiceAccounting({
get_agentic_rectification_case_dossier: () => dossier,
get_agentic_rectification_case_compute: () => computeFixture(),
...extra,
});
}
function choiceAccounting(overrides: Parameters<typeof fakeAccounting>[0] = {}) {
const receipts = new Map<string, Record<string, unknown>>();
return fakeAccounting({
@@ -172,6 +499,15 @@ test("choice quotes come from the option label, not the assistant question year"
assert.equal(userVisibleChoiceLine(card, "A"), "A. 是,大概就在那段时间");
});
test("synthetic choice taps are not kept as chat user lines", () => {
assert.equal(isStructuredChoiceUserText("A. 发挥明显失常或压力很大"), true);
assert.equal(isStructuredChoiceUserText("先这样"), true);
assert.equal(isStructuredChoiceUserText("先这样,先看当前范围"), true);
assert.equal(isStructuredChoiceUserText("盘外核对(不计分):A. 明确发生且时间吻合"), true);
assert.equal(isStructuredChoiceUserText("2016年我上了大学"), false);
assert.equal(isStructuredChoiceUserText("没有,那年很顺利"), false);
});
test("clicking A applies the choice without invoking a language model", async () => {
const accounting = choiceAccounting();
const applied = await applyRectificationChoice(accounting.client, {
@@ -202,7 +538,7 @@ test("clicking A applies the choice without invoking a language model", async ()
}));
const fns = accounting.calls.map((call) => call.fn);
assert.ok(fns.includes("apply_agentic_rectification_choice_action"));
assert.ok(fns.includes("append_agentic_rectification_turn"));
assert.equal(fns.includes("append_agentic_rectification_turn"), false);
assert.equal(fns.includes("record_agentic_rectification_evidence_batch"), false);
assert.equal(fns.some((fn) => fn === "create_agentic_rectification_run_attempt" || fn.includes("stream")), false);
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
@@ -219,6 +555,129 @@ test("clicking A applies the choice without invoking a language model", async ()
|| applied.nextAction.type === "ask_fact_collection", true);
});
test("answering a discriminator persists the next dated card so GET still has a tap target", async () => {
const closed = parseV9CaseDossier(twoProbeDossier());
assert.ok(closed);
closed.conversationSummary.activeFocus = null;
assert.equal(choiceCardFromCaseDossier(closed), null);
const accounting = persistChoiceAccounting(twoProbeDossier(), {
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: NEXT_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-28T07:37:50.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: CHOICE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: RELOCATION_2015_PROBE.id,
optionId: "C",
expectedRevision: twoProbeInference().revision,
});
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus, JSON.stringify(accounting.calls.map((call) => call.fn)));
assert.equal(setFocus.args.p_intent, "distinguish_candidates");
const schema = setFocus.args.p_expected_answer_schema as { semantic_key?: string; choice?: { prompt?: string } };
assert.equal(schema.semantic_key, EDUCATION_2014_PROBE.semantic_key);
assert.match(schema.choice?.prompt ?? "", /2014/);
assert.doesNotMatch(schema.choice?.prompt ?? "", /2015/);
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, true);
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
assert.equal(applied.narration, "接下来请点选下面这一问。");
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_assistant_message, "接下来请点选下面这一问。");
const refreshed = parseV9CaseDossier(twoProbeDossier());
assert.ok(refreshed);
const answered = twoProbeInference();
refreshed.latestResult = {
...refreshed.latestResult!,
decisionReceipt: {
...(refreshed.latestResult?.decisionReceipt ?? {}),
inference_state: {
...answered,
answered_probes: [{
probe_id: RELOCATION_2015_PROBE.id,
semantic_key: RELOCATION_2015_PROBE.semantic_key,
candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash,
answer_class: "no",
classified_from: "choice",
}],
},
},
};
refreshed.conversationSummary.activeFocus = {
id: NEXT_FOCUS_ID,
caseId: CASE_ID,
questionId: String(setFocus.args.p_question_id),
intent: String(setFocus.args.p_intent),
targetEvidenceId: null,
targetDomain: typeof setFocus.args.p_target_domain === "string" ? setFocus.args.p_target_domain : "education",
targetKind: null,
expectedAnswerSchema: schema,
status: "active",
askedAt: "2026-08-28T07:37:50.000Z",
resolvedAt: null,
};
const card = choiceCardFromCaseDossier(refreshed);
assert.ok(card);
assert.equal(card.focus_id, NEXT_FOCUS_ID);
assert.match(card.prompt, /2014/);
assert.doesNotMatch(card.prompt, /2015/);
});
test("answering the last discriminator persists a year-locked family collect, not a yearless D24 card", async () => {
const accounting = persistChoiceAccounting(familyCollectDossier(), {
set_agentic_rectification_conversation_focus: () => {
throw new Error("yearless D24 must not persist a scoring focus");
},
});
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: CHOICE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: RELOCATION_2015_PROBE.id,
optionId: "C",
expectedRevision: familyCollectInference().revision,
});
assert.equal(
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
false,
JSON.stringify(accounting.calls.map((call) => call.fn)),
);
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, false);
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
assert.match(applied.narration, /2021/);
assert.match(applied.narration, /家人|结婚|添丁|住院/);
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 ?? ""), /2021/);
});
test("replaying the same actionId does not duplicate the applied receipt", async () => {
const accounting = choiceAccounting();
const command = {
@@ -286,6 +745,7 @@ test("stop_and_review does not write an inference transition", async () => {
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
assert.equal(persist?.args.p_inference, null);
assert.equal(persist?.args.p_focus_status, "skipped");
assert.ok(accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn"));
});
test("turn_decision stays inside the configured byte budget", () => {
@@ -356,6 +816,10 @@ test("structured choice only continues through the agent when another question i
assert.equal(shouldContinueAfterStructuredChoice({ type }), false);
}
assert.equal(shouldContinueAfterStructuredChoice(null), false);
assert.equal(
shouldContinueAfterStructuredChoice({ type: "ask_candidate_discriminator" }, { nextInterviewPersisted: true }),
false,
);
});
test("structured choice narration never persists a fake loading state", () => {
@@ -384,8 +848,10 @@ test("the public agent route treats structured choice as a non-model command", (
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /isPersistedFocusId\(focusId\)/);
assert.match(chat, /focusId,/);
assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction\)/);
assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction, payload\)/);
assert.match(chat, /send\("read_only", ""\)/);
assert.match(chat, /willContinue/);
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
assert.match(chat, /回到最新/);
assert.match(chat, /followTailRef\.current/);
});
@@ -7,6 +7,7 @@ import {
HOLDOUT_MESSAGE_PREFIX,
isHoldoutVerificationQuote,
lifePeriodLabel,
preferConcreteChoicePrompt,
mergeChoiceCard,
parseAgentChoiceCopy,
parseRectificationChoiceCard,
@@ -14,6 +15,7 @@ import {
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import { QUALITY_STYLE_OPTIONS } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
const DYNAMIC_STYLE_OPTIONS = [
@@ -288,6 +290,12 @@ test("life period only reuses years from the same domain", () => {
assert.equal(lifePeriodLabel([
{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null },
], "relocation"), "那段时间");
assert.equal(lifePeriodLabel([
{ status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null },
], "career"), "2020 年 4 月前后");
assert.equal(lifePeriodLabel([
{ status: "confirmed", domain: "relationship", datePrecision: "day", occurredFrom: "2024-08-08", occurredTo: null },
], "relationship"), "2024 年 8 月前后");
});
test("choice frame requires a real non-empty event family", () => {
@@ -306,6 +314,124 @@ test("choice frame requires a real non-empty event family", () => {
}, { probes: [{ ...MOVE_PROBE, event_family: " " }] }), null);
});
test("yearless D24 quality cards do not borrow a recorded education year", () => {
const frame = buildChoiceFrame({
method_id: "d5_education",
ask_theme: "education_style",
domain: "education",
user_prompt_hint: "unused",
choice_kind: "event_quality",
semantic_key: "varga.d24.05:00/05:07",
style_options: QUALITY_STYLE_OPTIONS,
}, {
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
probes: [{
...MOVE_PROBE,
year: 0,
year_label: "当前这几个候选",
domain: "education",
event_family: "学业或考试发挥失常、压力特别大",
choice_kind: "event_quality",
semantic_key: "varga.d24.05:00/05:07",
style_options: QUALITY_STYLE_OPTIONS,
}],
});
assert.equal(frame, null);
});
test("yearless family cards do not attach a scoring frame", () => {
const frame = buildChoiceFrame({
method_id: "relatives",
ask_theme: "family_event",
domain: "family",
user_prompt_hint: "unused",
choice_kind: "existence",
}, {
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
probes: [{
...MOVE_PROBE,
year: 0,
year_label: "当前这几个候选",
domain: "family",
event_family: "家人结婚、添丁或住院",
choice_kind: "existence",
semantic_key: "varga.d12.05:00/05:07",
}],
});
assert.equal(frame, null);
});
test("yearless family cards do not borrow a recorded family year", () => {
const frame = buildChoiceFrame({
method_id: "relatives",
ask_theme: "family_event",
domain: "family",
user_prompt_hint: "unused",
choice_kind: "existence",
}, {
evidence: [{
status: "confirmed",
domain: "family",
datePrecision: "year",
occurredFrom: "2018-01-01",
occurredTo: null,
}],
probes: [{
...MOVE_PROBE,
year: 0,
year_label: "当前这几个候选",
domain: "family",
event_family: "家人结婚、添丁或住院",
choice_kind: "existence",
semantic_key: "varga.d12.05:00/05:07",
}],
});
assert.equal(frame, null);
});
test("GET upgrades a persisted placeholder stem to the recorded year lock", () => {
assert.equal(
preferConcreteChoicePrompt(
"2016 年前后,有没有学业或考试发挥失常、压力特别大的时候?",
"当前这几个候选 · 学业、考试发挥或学习压力出现明显变化",
),
"2016 年前后,有没有学业或考试发挥失常、压力特别大的时候?",
);
assert.equal(
preferConcreteChoicePrompt(
"2016 年前后,有没有学业或考试发挥失常、压力特别大的时候?",
"2016 年前后 · 学业、考试发挥或学习压力出现明显变化",
),
"2016 年前后,有没有学业或考试发挥失常、压力特别大的时候?",
);
assert.equal(
preferConcreteChoicePrompt(
"2018 年前后,有没有开始一段认真关系、分手或结婚?",
"2018 年前后,有没有认真关系进入、结束或关系观明显转变?",
),
"2018 年前后,有没有开始一段认真关系、分手或结婚?",
);
assert.equal(
preferConcreteChoicePrompt(
"2018 年前后,有没有家人结婚、添丁或住院?",
"那段时间,有没有家人相关的明显变化?",
),
"2018 年前后,有没有家人结婚、添丁或住院?",
);
});
test("choice frame completes existence options and rejects illegal or non-renderable varga styles", () => {
const build = (style_options: DiscriminatingEventProbe["style_options"], extra: Partial<DiscriminatingEventProbe> = {}) => buildChoiceFrame({
method_id: "d4_home",
@@ -891,6 +1017,175 @@ test("GET choice_card stays after coverage when remaining minutes still split on
assert.equal(card.prompt, SAMPLE_COPY.prompt);
});
test("GET shows an engine-dated career dasha instead of locking D24 to the recorded education year", () => {
const careerKey = "career.2012.11.dasha_boundary";
const careerCopy = {
prompt: "2012 年 11 月前后,有没有入职、升职或职责明显加重?",
option_a: DYNAMIC_STYLE_OPTIONS[0]!.label,
option_b: DYNAMIC_STYLE_OPTIONS[1]!.label,
option_c: DYNAMIC_STYLE_OPTIONS[2]!.label,
option_d: DYNAMIC_STYLE_OPTIONS[3]!.label,
options: DYNAMIC_STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
};
const inferenceCandidates = [
{ id: "04:47", time: "04:47", cluster_range: ["04:47", "04:47"] as const, prior_score: 5, posterior_score: 5, probability: 0.05, status: "active", rank: 6, strong_conflict_count: 0 },
{ id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"] as const, prior_score: 21, posterior_score: 21, probability: 0.21, status: "active", rank: 1, strong_conflict_count: 0 },
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"] as const, prior_score: 18, posterior_score: 18, probability: 0.18, status: "active", rank: 2, strong_conflict_count: 0 },
{ id: "05:12", time: "05:12", cluster_range: ["05:12", "05:12"] as const, prior_score: 18, posterior_score: 18, probability: 0.18, status: "active", rank: 3, strong_conflict_count: 0 },
{ id: "05:14", time: "05:14", cluster_range: ["05:14", "05:14"] as const, prior_score: 17, posterior_score: 17, probability: 0.17, status: "active", rank: 4, strong_conflict_count: 0 },
{ id: "05:15", time: "05:15", cluster_range: ["05:15", "05:15"] as const, prior_score: 3, posterior_score: 3, probability: 0.03, status: "active", rank: 9, strong_conflict_count: 0 },
];
const candidateSetId = "04:45-05:15:04:47,05:00,05:07,05:12,05:14,05:15";
const d24Key = "varga.d24.04:47/04:51|04:53/04:59/05:00/05:07|05:12/05:14|05:15";
const d24Split = `${candidateSetId}:${d24Key}`;
const d24Outcomes = [
{ answer_class: "yes", supports: ["04:47"], conflicts: ["05:00", "05:07", "05:12", "05:14", "05:15"] },
{ answer_class: "weak_yes", supports: ["05:00", "05:07"], conflicts: ["04:47", "05:12", "05:14", "05:15"] },
{ answer_class: "no", supports: ["05:12", "05:14"], conflicts: ["04:47", "05:00", "05:07", "05:15"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
];
const card = choiceCardFromCaseDossier({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: "2016-01-01",
eventKind: "education_start",
}, {
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: "2020-04-01",
eventKind: "career_entry",
}, {
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-10-01",
occurredTo: "2020-10-01",
eventKind: "career_exit",
}, {
status: "confirmed",
domain: "career",
datePrecision: "day",
occurredFrom: "2024-04-07",
occurredTo: null,
eventKind: "career_entry",
}, {
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
}, {
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
}],
conversationSummary: {
activeFocus: {
id: FOCUS_ID,
questionId: `probe:${careerKey}`,
intent: "distinguish_candidates",
targetDomain: "career",
targetKind: null,
expectedAnswerSchema: {
choice: careerCopy,
probe_id: `event:${careerKey}`,
semantic_key: careerKey,
candidate_split_hash: "career.2012.11",
choice_kind: "existence",
},
},
declinedSkippedTopics: [],
},
latestResult: {
resultId: "result-d24-open",
selectionAllowed: true,
confirmationAllowed: false,
candidates: inferenceCandidates.map((item) => ({
time: item.time,
rank: item.rank,
relativeSupport: item.posterior_score,
})),
decisionReceipt: {
discriminating_event_probes: [{
year: 2012,
year_label: "2012 年 11 月前后",
domain: "career",
event_family: "入职、升职或职责明显加重",
source: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2012 年 11 月前后。",
role: "distinguish",
phase: "candidate_discriminator",
information_gain: 1.3356,
semantic_key: careerKey,
candidate_split_hash: "career.2012.11",
candidate_ids: ["04:47", "05:00", "05:15"],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:47"], conflicts: ["05:00", "05:15"] },
{ answer_class: "weak_yes", supports: ["04:47"], conflicts: ["05:00", "05:15"] },
{ answer_class: "no", supports: ["05:00", "05:15"], conflicts: ["04:47"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
style_options: DYNAMIC_STYLE_OPTIONS,
}],
inference_state: {
algorithm_version: "rectification-inference-v1",
candidate_set_id: candidateSetId,
revision: 1,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:45",
range_end: "05:15",
candidates: inferenceCandidates,
events: [],
probes: [{
id: `contrast:${d24Key}`,
year: 0,
domain: "education",
source: "varga_contrast",
question: "引擎给出的区分机会绑定 D24。",
semantic_key: d24Key,
candidate_ids: inferenceCandidates.map((item) => item.time),
information_gain: 2.503258334775646,
expected_outcomes: d24Outcomes,
candidate_split_hash: d24Split,
}],
answered_probes: [],
rounds: [],
entropy: 2.0,
representative_time: "05:00",
credible_range: ["05:00", "05:14"],
},
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
turns: [],
});
assert.ok(card);
assert.equal(card.focus_id, FOCUS_ID);
assert.match(card.prompt, /2012 年 11 月前后,有没有入职、升职或职责明显加重?/);
assert.doesNotMatch(card.prompt, /2016 年前后/);
assert.doesNotMatch(card.prompt, /当前这几个候选/);
assert.doesNotMatch(card.prompt, / · /);
assert.equal(card.options.length, 4);
assert.equal(card.options.every((option) => option.role === "primary"), true);
});
test("GET choice_card stays hidden without a persisted focus after 没有了", () => {
const card = choiceCardFromCaseDossier({
evidence: [{
@@ -1058,8 +1353,8 @@ test("career event_quality uses the supplied dynamic labels", () => {
assert.ok(copy);
assert.match(copy.prompt, /2020 年前后/);
assert.match(copy.prompt, /入职、升职或职责明显加重/);
assert.doesNotMatch(copy.prompt, /有没有/);
assert.match(copy.prompt, /·/);
assert.match(copy.prompt, /有没有/);
assert.doesNotMatch(copy.prompt, / · /);
assert.equal(copy.option_a, DYNAMIC_STYLE_OPTIONS[0].label);
assert.doesNotMatch(copy.option_a, /失常/);
});
@@ -336,31 +336,36 @@ test("scored inference catalog outranks a low-gain Python career probe when snap
const plan = buildMethodFollowupPlan({
evidence,
eventProbes: packet.probes.flatMap((probe) => probe.semanticKey.startsWith("career.")
? [{
year: 2023,
year_label: "2023 年前后",
domain: "career",
event_family: "入职、升职或职责明显加重",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2023 年前后。",
role: "distinguish",
phase: "candidate_discriminator",
information_gain: 0.56,
semantic_key: "career.2023.dasha_activation",
candidate_split_hash: "500ce694938305201fbab9ba",
candidate_ids: ["04:45", "05:00", "05:14", "05:15"],
expected_outcomes: careerOutcomes,
}]
: []),
eventProbes: [{
year: 2023,
year_label: "2023 年前后",
domain: "career",
event_family: "入职、升职或职责明显加重",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2023 年前后。",
role: "distinguish",
phase: "candidate_discriminator",
information_gain: 0.56,
semantic_key: "career.2023.dasha_activation",
candidate_split_hash: "500ce694938305201fbab9ba",
candidate_ids: ["04:45", "05:00", "05:14", "05:15"],
expected_outcomes: careerOutcomes,
style_options: [
{ label: "明确发生且时间吻合", answer_class: "yes" },
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
{ label: "明确没有发生", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
],
}],
contrastPacket: packet,
candidatesSeparated: false,
});
assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d24\./);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /career\.2023/);
assert.equal(plan.next_followup?.semantic_key, "career.2023.dasha_activation");
assert.match(plan.next_followup?.choice_frame?.period ?? "", /2023 年前后/);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
});
test("career and relationship training still discriminates before family or occupation coverage", () => {
@@ -376,11 +381,41 @@ test("career and relationship training still discriminates before family or occu
{ id: "05:12", time: "05:12", cluster_range: ["05:12", "05:12"], prior_score: 17, posterior_score: 17, probability: 0.21, status: "active", rank: 3, strong_conflict_count: 0 },
];
const evidence = [
{ id: "edu-2016", status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null, eventKind: "education_milestone" },
{ id: "career-exit", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-10-01", occurredTo: null, eventKind: "career_exit" },
{ id: "career-entry", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry" },
{ id: "rel-start", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2024-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "rel-end", status: "confirmed", domain: "relationship", datePrecision: "day", occurredFrom: "2024-08-08", occurredTo: null, eventKind: "relationship_end" },
];
const familyProbe = {
year: 2013,
year_label: "2013 年 3 月前后",
domain: "family",
event_family: "家人结婚、添丁或住院",
source: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2013 年 3 月前后。",
role: "distinguish",
phase: "candidate_discriminator",
information_gain: 1.2,
semantic_key: "family.2013.03.dasha_boundary",
candidate_split_hash: "family.2013.03",
candidate_ids: ["05:00", "05:07", "05:12"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07", "05:12"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00", "05:12"] },
{ answer_class: "no", supports: ["05:12"], conflicts: ["05:00", "05:07"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
style_options: [
{ label: "明确发生且时间吻合", answer_class: "yes" },
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
{ label: "明确没有发生", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
],
};
const dossier = {
evidence,
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
@@ -388,6 +423,7 @@ test("career and relationship training still discriminates before family or occu
resultId: "result-unseparated",
candidates: [],
decisionReceipt: {
discriminating_event_probes: [familyProbe],
inference_state: {
algorithm_version: "rectification-inference-v1",
candidate_set_id: "05:00-05:12:05:00,05:07,05:12",
@@ -398,6 +434,7 @@ test("career and relationship training still discriminates before family or occu
range_end: "05:12",
candidates: inferenceCandidates,
events: [
{ id: "edu-2016", year: 2016, usage: "training", domain: "education", precision: "year" },
{ id: "career-exit", year: 2020, usage: "training", domain: "career", precision: "month" },
{ id: "career-entry", year: 2020, usage: "holdout", domain: "career", precision: "month" },
{ id: "rel-start", year: 2024, usage: "training", domain: "relationship", precision: "month" },
@@ -440,12 +477,15 @@ test("career and relationship training still discriminates before family or occu
const packet = contrastPacketFromDossier(dossier);
const plan = buildMethodFollowupPlan({
evidence,
eventProbes: [familyProbe],
contrastPacket: packet,
sessionOutcome: decision.sessionOutcome,
});
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d24/);
assert.equal(plan.next_followup?.semantic_key, "family.2013.03.dasha_boundary");
assert.ok(plan.next_followup?.choice_frame);
assert.match(plan.next_followup?.choice_frame?.period ?? "", /2013 年 3 月前后/);
assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /2016 年前后/);
assert.equal(conversationalSessionOutcome({
selectionAllowed: false,
proposeAllowed: false,
+260 -25
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import {
askedKeysFromLedgerEvidence,
@@ -1741,20 +1741,25 @@ test("D9/D10 contrast after occupation coverage asks a discriminator, not adopt"
{ layer: "d10", signs: ["巨蟹", "狮子", "处女"] },
],
probes: [{
probeId: "contrast:varga.d10.巨蟹|狮子|处女",
probeId: "contrast:varga.d10.05:00|05:01|05:02",
candidateSetVersion: "05:00-05:02",
question: "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事。",
expectedOutcomes: [
{ outcomeId: "supports_巨蟹", supportsCandidateIds: ["巨蟹"], conflictsCandidateIds: ["狮子", "处女"] },
{ outcomeId: "supports_狮子", supportsCandidateIds: ["狮子"], conflictsCandidateIds: ["巨蟹", "处女"] },
{ outcomeId: "supports_处女", supportsCandidateIds: ["处女"], conflictsCandidateIds: ["巨蟹", "狮子"] },
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:01", "05:02"] },
{ outcomeId: "weak_yes", supportsCandidateIds: ["05:01"], conflictsCandidateIds: ["05:00", "05:02"] },
{ outcomeId: "no", supportsCandidateIds: ["05:02"], conflictsCandidateIds: ["05:00", "05:01"] },
],
candidateSplitHash: "varga.d10.巨蟹|狮子|处女",
candidateSplitHash: "varga.d10.05:00|05:01|05:02",
informationGain: 0.12,
sourceFeatures: [{ technique: "D10", calculationResultId: RESULT_ID }],
domain: "career",
year: null,
semanticKey: "varga.d10.巨蟹|狮子|处女",
semanticKey: "varga.d10.05:00|05:01|05:02",
choiceKind: "varga_style",
styleOptions: [
{ label: "做事偏领导推进", answerClass: "yes", sign: "白羊座" },
{ label: "做事偏研究转化", answerClass: "weak_yes", sign: "天蝎座" },
],
}],
},
});
@@ -1789,7 +1794,7 @@ test("D9/D10 contrast after occupation coverage asks a discriminator, not adopt"
}), "discriminate_candidates");
});
test("answered duty language skips window D10 and uses remaining D24", () => {
test("answered duty language skips window D10 and collects a dated education event instead of a yearless D24 card", () => {
const packet = {
candidateSetVersion: "05:00-05:07",
vargaDifferences: [
@@ -1829,11 +1834,13 @@ test("answered duty language skips window D10 and uses remaining D24", () => {
contrastPacket: packet,
});
assert.equal(plan.next_followup?.domain, "education");
assert.equal(plan.next_followup?.semantic_key, "varga.d24.05:00/05:06|05:07");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住年份/);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d10/);
});
test("low-gain career event probe does not outrank a renderable high-gain D24 contrast probe", () => {
test("yearless D24 yields to a dated career dasha instead of borrowing the recorded education year", () => {
const plan = buildMethodFollowupPlan({
evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"),
eventProbes: [{
@@ -1866,15 +1873,183 @@ test("low-gain career event probe does not outrank a renderable high-gain D24 co
},
candidatesSeparated: false,
});
assert.equal(plan.next_followup?.semantic_key, "varga.d24.05:00/05:07|05:10|05:14");
assert.equal(plan.next_followup?.choice_frame?.option_d_answer_class, "unsure");
assert.equal(plan.next_followup?.choice_frame?.period, "当前这几个候选");
assert.match(plan.next_followup?.choice_frame?.prompt ?? "", /学业|考试发挥|学习压力/);
assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /入职、升职/);
assert.ok((plan.next_followup?.selection_score ?? 0) > 0.56);
assert.equal(plan.next_followup?.semantic_key, "career.2023.dasha_activation");
assert.equal(plan.next_followup?.choice_frame?.period, "2023 年前后");
assert.match(plan.next_followup?.choice_frame?.prompt ?? "", /2023 年前后,有没有/);
assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /2016 年前后/);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
});
test("already-open low-gain career card yields to the high-gain D24 catalog winner", () => {
const YEARLESS_D12 = {
probeId: "contrast:varga.d12.05:00/05:07",
candidateSetVersion: "05:00-05:07",
question: "当前几个候选在六亲盘上还分得开。请核对一段还没用进评分的家人前事。",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07"] },
{ outcomeId: "no", supportsCandidateIds: ["05:07"], conflictsCandidateIds: ["05:00"] },
],
candidateSplitHash: "varga.d12.05:00/05:07",
informationGain: 3.5,
sourceFeatures: [{ technique: "D12", calculationResultId: RESULT_ID }],
domain: "family",
year: null,
semanticKey: "varga.d12.05:00/05:07",
choiceKind: "existence" as const,
};
test("yearless family contrast yields to a dated career discriminator", () => {
const plan = buildMethodFollowupPlan({
evidence: [
datedEvidence("education", "2016"),
datedEvidence("relationship", "2018"),
datedEvidence("relocation", "2014"),
datedEvidence("health_pressure", "2012"),
{
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
],
eventProbes: [{
...CAREER_CONFLICT_PROBE,
year: 2020,
year_label: "2020 年 4 月前后",
information_gain: 0.56,
semantic_key: "career.2020.dasha_activation",
}],
contrastPacket: {
candidateSetVersion: "05:00-05:07",
vargaDifferences: [],
probes: [YEARLESS_D12],
},
candidatesSeparated: false,
});
assert.notEqual(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame != null, true);
assert.match(plan.next_followup?.choice_frame?.period ?? "", /2020 年 4 月前后/);
assert.match(plan.next_followup?.choice_frame?.prompt ?? "", /2020 年 4 月前后,有没有/);
assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /那段时间/);
});
test("yearless family contrast without a dated discriminator collects a dated family event", () => {
const plan = buildMethodFollowupPlan({
evidence: [
...CLASSIC_COVERAGE.filter((item) => item.domain !== "horary" && item.domain !== "family"),
datedEvidence("relocation", "2014"),
],
contrastPacket: {
candidateSetVersion: "05:00-05:07",
vargaDifferences: [],
probes: [YEARLESS_D12],
},
candidatesSeparated: false,
});
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住时间/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /A\/B\/C\/D/);
});
test("family collect attaches the collection-probe year instead of a yearless D24 card", () => {
const plan = buildMethodFollowupPlan({
evidence: [
datedEvidence("career", "2020"),
datedEvidence("career", "2024"),
datedEvidence("relationship", "2024"),
datedEvidence("career", "2026"),
datedEvidence("relationship", "2024", { datePrecision: "day" }),
datedEvidence("relationship", "2025", { datePrecision: "day" }),
],
askedProbeKeys: ["relocation.2015.05.dasha_boundary"],
contrastPacket: {
candidateSetVersion: "05:00-05:14",
vargaDifferences: [],
probes: [{
probeId: "contrast:varga.d24.05:00/05:14",
candidateSetVersion: "05:00-05:14",
question: "引擎给出的区分机会绑定 D24。",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:14"] },
{ outcomeId: "no", supportsCandidateIds: ["05:14"], conflictsCandidateIds: ["05:00"] },
],
candidateSplitHash: "varga.d24.05:00/05:14",
informationGain: 2.5,
sourceFeatures: [{ technique: "D24", calculationResultId: RESULT_ID }],
domain: "education",
year: null,
semanticKey: "varga.d24.05:00/05:14",
}],
},
evidenceCollectionProbes: [{
year: 2021,
year_label: "2021 年前后",
domain: "family",
event_family: "家人结婚、添丁或住院",
source: "age_band",
tracks: ["vimshottari", "narayana"],
tracks_agree: false,
unique_minute_claim: false,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect",
phase: "evidence_collection",
information_gain: 0,
semantic_key: "family.2021",
}],
candidatesSeparated: false,
});
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
assert.match(plan.next_followup?.year_label ?? "", /2021/);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后,家里有没有/);
});
test("already-open yearless family card does not keep a scoring frame", () => {
const plan = buildMethodFollowupPlan({
evidence: [
...CLASSIC_COVERAGE.filter((item) => item.domain !== "horary" && item.domain !== "family"),
datedEvidence("relocation", "2014"),
],
contrastPacket: {
candidateSetVersion: "05:00-05:07",
vargaDifferences: [],
probes: [YEARLESS_D12],
},
candidatesSeparated: false,
activeFocus: {
intent: "distinguish_candidates",
targetDomain: "family",
targetKind: "family_event",
expectedAnswerSchema: {
semantic_key: "varga.d12.05:00/05:07",
choice: {
prompt: "那段时间,有没有家人相关的明显变化?",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: DYNAMIC_STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
},
},
});
assert.notEqual(plan.next_followup?.choice_frame?.prompt, "那段时间,有没有家人相关的明显变化?");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
});
test("already-open dated career card stays ahead of a yearless D24 catalog row", () => {
const plan = buildMethodFollowupPlan({
evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"),
eventProbes: [{
@@ -1916,8 +2091,8 @@ test("already-open low-gain career card yields to the high-gain D24 catalog winn
},
},
});
assert.equal(plan.next_followup?.semantic_key, "varga.d24.05:00/05:07|05:10|05:14");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /career\.2023/);
assert.equal(plan.next_followup?.semantic_key, "career.2023.dasha_activation");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
});
const DUMP_COVERAGE = [
@@ -2004,7 +2179,7 @@ const DUMP_SCORES = [
{ time: "05:04", score: 33 },
];
test("coverage-complete tie with encoded D24/D10 asks remaining D4, not enrollment or breakup", () => {
test("coverage-complete tie with encoded D24/D10 collects a dated move instead of a yearless D4 card", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: DUMP_SCORES.map((item) => item.time),
@@ -2018,9 +2193,12 @@ test("coverage-complete tie with encoded D24/D10 asks remaining D4, not enrollme
});
assert.equal(plan.next_followup?.domain, "relocation");
assert.equal(plan.next_followup?.kind_hint, "home_change");
assert.match(plan.next_followup?.semantic_key ?? "", /varga\.d4/);
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住时间的搬家/);
assert.doesNotMatch(plan.next_followup?.kind_hint ?? "", /education_start|relationship_end/);
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /大学哪年入学|高考是 \d{4}|哪年毕业/);
assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /那段时间/);
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
@@ -2114,7 +2292,7 @@ test("structured paused state ends evidence collection without parsing user copy
});
test("covered-domain existence probes are skipped; the highest remaining discriminator wins", () => {
test("same-year existence probes are skipped; a different-year dasha still ranks", () => {
const evidence = [
datedEvidence("education", "2016"),
datedEvidence("relationship", "2018"),
@@ -2125,12 +2303,69 @@ test("covered-domain existence probes are skipped; the highest remaining discrim
evidence,
eventProbes: [{
...CAREER_CONFLICT_PROBE,
year: 2015,
year_label: "2015 年前后",
information_gain: 0.56,
semantic_key: "career.2015.dasha_activation",
candidate_split_hash: "set-test:career:2015",
}],
});
assert.notEqual(skipped.next_followup?.semantic_key, "career.2015.dasha_activation");
const dated = buildMethodFollowupPlan({
evidence,
eventProbes: [{
...CAREER_CONFLICT_PROBE,
year: 2018,
year_label: "2018 年前后",
information_gain: 0.56,
semantic_key: "career.2018.dasha_activation",
}],
});
assert.notEqual(skipped.next_followup?.domain, "career");
assert.notEqual(skipped.next_followup?.source, "event_probe");
assert.equal(dated.next_followup?.semantic_key, "career.2018.dasha_activation");
assert.match(dated.next_followup?.choice_frame?.period ?? "", /2018 年前后/);
const childhood = buildMethodFollowupPlan({
evidence,
birthDate: "1997-08-08",
eventProbes: [{
...CAREER_CONFLICT_PROBE,
year: 2012,
year_label: "2012 年 11 月前后",
information_gain: 1.3,
semantic_key: "career.2012.11.dasha_boundary",
candidate_split_hash: "set-test:career:2012",
}, {
...CAREER_CONFLICT_PROBE,
year: 2019,
year_label: "2019 年前后",
information_gain: 0.4,
semantic_key: "career.2019.dasha_activation",
candidate_split_hash: "set-test:career:2019",
}],
});
assert.notEqual(childhood.next_followup?.semantic_key, "career.2012.11.dasha_boundary");
assert.equal(childhood.next_followup?.semantic_key, "career.2019.dasha_activation");
const adjacentRelationship = buildMethodFollowupPlan({
evidence: [
datedEvidence("education", "2016"),
datedEvidence("career", "2020"),
datedEvidence("relationship", "2024"),
datedEvidence("family", "2023"),
],
eventProbes: [{
...CAREER_CONFLICT_PROBE,
domain: "relationship",
event_family: "开始一段认真关系、分手或结婚",
year: 2023,
year_label: "2023 年 5 月前后",
information_gain: 0.9,
semantic_key: "relationship.2023.05.dasha_boundary",
candidate_split_hash: "set-test:relationship:2023",
}],
});
assert.notEqual(adjacentRelationship.next_followup?.semantic_key, "relationship.2023.05.dasha_boundary");
const d24 = {
probeId: "contrast:varga.d24.05:00|05:07|05:14",
@@ -2195,7 +2430,7 @@ test("covered-domain existence probes are skipped; the highest remaining discrim
},
candidatesSeparated: false,
});
assert.equal(d24Wins.next_followup?.semantic_key, d24.semanticKey);
assert.equal(d24Wins.next_followup?.semantic_key, d10.semanticKey);
});
test("three dated events with one holdout keep collecting instead of discriminating", () => {
@@ -209,7 +209,7 @@ test("complete dynamic event options persist a server-owned focus", async () =>
assert.equal(result.status, "created");
assert.equal(accounting.calls.length, 1);
assert.deepEqual(result.focus?.expectedAnswerSchema.choice, {
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
prompt: "2016 年前后,有没有升学结果或学习环境出现明显变化",
option_a: DYNAMIC_STYLE_OPTIONS[0].label,
option_b: DYNAMIC_STYLE_OPTIONS[1].label,
option_c: DYNAMIC_STYLE_OPTIONS[2].label,
@@ -28,9 +28,14 @@ test("does not publish intermediate tool-step text as answer.delta", () => {
isPublicTool,
);
assert.equal(afterTool.kind, "discard");
applyStepAnswerChunk(state, chunk("text-delta", { text: "记下了,2020年4月开始实习。" }), isPublicTool);
const live = applyStepAnswerChunk(
state,
chunk("text-delta", { text: "记下了,2020年4月开始实习。" }),
isPublicTool,
);
assert.deepEqual(live, { kind: "live", text: "记下了,2020年4月开始实习。" });
const finish = applyStepAnswerChunk(state, chunk("step-finish", { reason: "stop" }), isPublicTool);
assert.deepEqual(finish, { kind: "publish", pieces: ["记下了,2020年4月开始实习。"] });
assert.equal(finish.kind, "none");
});
test("never publishes reasoning-delta to the browser", () => {
@@ -47,7 +52,76 @@ test("publishes only the terminal no-tool step as assistant text", () => {
applyStepAnswerChunk(state, chunk("step-finish", { stepResult: { reason: "tool-calls" } }), isPublicTool);
assert.equal(shouldPublishStepText({ calledTool: true, text: "_probe" }, "tool-calls"), false);
applyStepAnswerChunk(state, chunk("step-start"), isPublicTool);
applyStepAnswerChunk(state, chunk("text-delta", { text: "已经记下实习。" }), isPublicTool);
const live = applyStepAnswerChunk(state, chunk("text-delta", { text: "已经记下实习。" }), isPublicTool);
assert.deepEqual(live, { kind: "live", text: "已经记下实习。" });
const finish = flushStepAnswerOnStreamFinish(state, "stop");
assert.deepEqual(finish, { kind: "publish", pieces: ["已经记下实习。"] });
assert.equal(finish.kind, "none");
});
test("streams terminal Chinese tokens live after compare", () => {
const state = createStepAnswerState();
applyStepAnswerChunk(
state,
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
isPublicTool,
);
const first = applyStepAnswerChunk(state, chunk("text-delta", { text: "记下了," }), isPublicTool);
const second = applyStepAnswerChunk(
state,
chunk("text-delta", { text: "2020年4月开始实习。" }),
isPublicTool,
);
assert.deepEqual(first, { kind: "live", text: "记下了," });
assert.deepEqual(second, { kind: "live", text: "2020年4月开始实习。" });
assert.equal(applyStepAnswerChunk(state, chunk("step-finish", { reason: "stop" }), isPublicTool).kind, "none");
});
test("retracts a live spoken prefix if that step later calls a public tool", () => {
const state = createStepAnswerState();
applyStepAnswerChunk(
state,
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
isPublicTool,
);
assert.deepEqual(
applyStepAnswerChunk(state, chunk("text-delta", { text: "记下了," }), isPublicTool),
{ kind: "live", text: "记下了," },
);
const retracted = applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-read-diagnostics" }),
isPublicTool,
);
assert.equal(retracted.kind, "retract");
applyStepAnswerChunk(
state,
chunk("tool-result", { toolName: "rectification-read-diagnostics" }),
isPublicTool,
);
const spoken = applyStepAnswerChunk(
state,
chunk("text-delta", { text: "诊断已经看过。接下来确认入职年份。" }),
isPublicTool,
);
assert.deepEqual(spoken, { kind: "live", text: "诊断已经看过。接下来确认入职年份。" });
});
test("does not live-publish English planning before a tool-call", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(state, chunk("text-delta", { text: "Let me set" }), isPublicTool).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(state, chunk("text-delta", { text: " _probe" }), isPublicTool).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-record-evidence-batch" }),
isPublicTool,
).kind,
"none",
);
});
+113 -3
View File
@@ -205,6 +205,14 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(
safePublicEvent({ type: "answer.delta", text: "你好", replace: true }),
{ type: "answer.delta", text: "你好", replace: true },
);
assert.deepEqual(
safePublicEvent({ type: "answer.delta", text: "你好", replace: false }),
{ type: "answer.delta", text: "你好" },
);
assert.equal(safePublicEvent({ type: "thinking.delta", text: "先核对升学" }), null);
assert.deepEqual(
safePublicEvent({ type: "activity.changed", activity: "reading_case" }),
@@ -482,6 +490,8 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("reasoning-start", { id: "r1" }),
chunk("reasoning-delta", { text: "我应该先……" }),
chunk("reasoning-end"),
@@ -495,8 +505,10 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
assert.equal(result.ok, true);
const deltas = emitted.filter((event) => event.type === "answer.delta");
assert.deepEqual(deltas, [
{ type: "answer.delta", text: "好的,先确认一下:" },
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "先确认一下:" },
]);
assert.equal(deltas.map((event) => event.text).join(""), "好的,先确认一下:");
assert.deepEqual(
emitted.filter((event) => event.type === "thinking.delta"),
[],
@@ -671,6 +683,40 @@ test("a length-limited spoken answer is not billed or persisted as a completed t
assert.equal(turnFinalize?.args.p_successful_attempt_id, null);
});
test("length after a stamped open_question still completes without failing the choice card", async () => {
const prompt = "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?";
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, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
}),
chunk("text-delta", { text: "实习和离职的时间点都记下了,谢谢。" }),
chunk("finish", { stepResult: { reason: "length" } }),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "接下来看下面这一问。");
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("answer deltas and tool activity are published before billing settles", async () => {
let billingStarted = false;
const seenBeforeBilling: string[] = [];
@@ -1399,16 +1445,51 @@ test("timeout after a stamped open_question still completes without pasting the
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "记下了。");
assert.equal(result.answerText, "接下来看下面这一问。");
assert.doesNotMatch(result.answerText, /有没有/);
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "记下了。" }],
[{ type: "answer.delta", text: "接下来看下面这一问。" }],
);
});
test("open_question acknowledgements stream live instead of waiting for flush", async () => {
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
}),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("text-delta", { text: "好的," }),
chunk("text-delta", { text: "毕业这条也记下了。" }),
chunk("text-delta", { text: "\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "好的,毕业这条也记下了。");
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "毕业这条也记下了。" },
],
);
assert.doesNotMatch(JSON.stringify(emitted.filter((event) => event.type === "answer.delta")), /入学考试/);
});
test("persisted choice prompt replaces a competing model follow-up without a topic denylist", async () => {
const spoken = "好的,2020 年 6 月毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试,你当时发挥明显失常、或者压力特别大,有没有发生过?";
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
@@ -1480,6 +1561,35 @@ test("persisted choice card owns a matching year-locked follow-up", async () =>
);
});
test("structured-choice acknowledgements are not kept as a second assistant reply", () => {
const prompt = "2012 年 11 月前后,有没有入职、升职或职责明显加重?";
assert.equal(
bindSpokenToOpenQuestion("已记下你刚才的选择,候选比较也随之更新了。", prompt),
"",
);
assert.equal(
bindSpokenToOpenQuestion("已记录你的选择,并更新了候选比较。接下来这一问和家里有关,请看下方选项。", prompt),
"",
);
assert.equal(bindSpokenToOpenQuestion("记下了。", prompt), "记下了。");
});
test("choice-card acknowledgements do not repeat a different-domain event", () => {
const intern = "实习和离职的时间点都记下了,谢谢。";
assert.equal(
bindSpokenToOpenQuestion(intern, "2005 年 5 月前后,有没有搬家、离乡或长期异地?"),
"",
);
assert.equal(
bindSpokenToOpenQuestion(intern, "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?"),
"",
);
assert.equal(
bindSpokenToOpenQuestion("好,实习和离职都记下了。", "2023 年前后 · 入职、升职或职责明显加重"),
"好,实习和离职都记下了。",
);
});
test("lock-only prompt is not spliced into speech", () => {
const lock = "2023 年前后 · 入职、升职或职责明显加重";
assert.equal(bindSpokenToOpenQuestion("", lock), "");