Files
Jyotisha/frontend/tests/rectification-spoken-answer.test.ts
Jesse_Chen 5e34dd69ce fix(web): keep rectification replies as model text, hide thinking
Give the interview a hidden reasoning channel so planning leaves the spoken reply, publish terminal text-delta as-is, and stop regex or Case templates from replacing the model.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:34:25 +08:00

211 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* These helpers recover old leaked Turns on hydrate. They are not the live
* spoken-answer classifier; `runV9AgentTurn` must not call them.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
finalizeRectificationSpokenAndThinking,
isRectificationProcessNarration,
nextStableChannelDelta,
settleRectificationSpokenAndThinking,
splitRectificationSpokenAndThinking,
} from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
test("a plain spoken follow-up stays on the answer channel", () => {
const spoken = [
"记下了,大约六岁入学小学。",
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
].join("\n\n");
assert.deepEqual(splitRectificationSpokenAndThinking(spoken), {
thinking: "",
spoken,
});
});
test("process self-talk leaves the spoken conclusion on the answer channel", () => {
const processTalk = [
"用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。",
"但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 yearoccurredFrom 只能按用户原话来。",
"我决定先按六岁入学记下来,再在正文里确认那句晚年份是不是口误。",
"让我调用 batch 写入 education_start。",
].join("\n\n");
const spoken = [
"入学小学这条先按大约六岁记下。你第一句提到的那个很晚的年份,我理解是口误对吗?",
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
].join("\n\n");
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`), {
thinking: processTalk,
spoken,
});
});
test("English process talk is classified as thinking, not the spoken answer", () => {
const split = splitRectificationSpokenAndThinking([
"The proposedKind value was rejected. Retrying with education_start.",
"记下了,那年九月上大学。",
].join("\n\n"));
assert.match(split.thinking, /proposedKind/);
assert.equal(split.spoken, "记下了,那年九月上大学。");
});
test("stable channel deltas only emit the newly classified suffix", () => {
assert.equal(nextStableChannelDelta("", "先核对升学年份。"), "先核对升学年份。");
assert.equal(
nextStableChannelDelta("先核对升学年份。", "先核对升学年份。\n\n再问初中。"),
"\n\n再问初中。",
);
assert.equal(nextStableChannelDelta("先核对升学年份。", "另一段"), "");
});
test("a lone process paragraph stays in thinking until a spoken conclusion arrives", () => {
const processTalk = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n`), {
thinking: processTalk,
spoken: "",
});
});
test("truncated third-person batch-tool narration stays in thinking", () => {
const truncated = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
assert.equal(isRectificationProcessNarration(truncated), true);
assert.deepEqual(splitRectificationSpokenAndThinking(truncated), {
thinking: truncated,
spoken: "",
});
assert.deepEqual(finalizeRectificationSpokenAndThinking(truncated), {
thinking: truncated,
spoken: "",
});
});
test("finalizing does not promote process-only self-talk into the spoken answer", () => {
const first = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
const last = "但按 skill 规则,日期精度真实保留,不得猜补。";
const processTalk = `${first}\n\n${last}`;
assert.deepEqual(finalizeRectificationSpokenAndThinking(processTalk), {
thinking: processTalk,
spoken: "",
});
});
test("Chinese interview planning after tools stays out of the spoken answer", () => {
const processTalk = [
"读取校正记录",
"整理多条事件证据",
"比较候选时间",
"职业类型已经记录,并重新计算了候选。虽然职业领域现在有信息了,但账户里仍然只有 3 件带评分日期的事件,还不足以拉开候选范围区间(当前不可分宽度 8 分钟)。",
"方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分),但还需要更多带日期的经历来区分候选。当前还应继续收集事件。",
"我用自然语言再问一件带大概年份的经历。根据 method_followup_plan,职业已经覆盖。当前 open_question 为空,next_action 为空。",
"候选区分尚未充分(not_separated),应继续收集。",
"让我继续访谈,问一件能帮助区分候选的职业前事。",
"本轮对照了Gochara、D1 本命盘、D10 事业分盘。",
].join("\n\n");
const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**又是什么时候离开的?只要个大概年份就行。";
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
assert.equal(split.spoken, spoken);
assert.match(split.thinking, /occupation_note/);
assert.match(split.thinking, /读取校正记录/);
assert.match(split.thinking, /本轮对照了/);
assert.doesNotMatch(split.thinking, /你大概是在哪一年入职/);
assert.equal(isRectificationProcessNarration("读取校正记录"), true);
assert.equal(isRectificationProcessNarration(spoken), false);
});
test("planning about candidate_contrast_packet stays out of the spoken answer", () => {
const processTalk = [
"这意味着:方法资料已齐,还不能出牌。",
"服务器给了 candidate_contrast_packetchoice_frame 已写好。",
"第 7 条边界:id=ask_candidate_discriminator 时不得 offer。",
].join("\n\n");
const spoken = "那次高考或重要考试,发挥有没有明显失常、压力很大?说有或没有就行。";
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
assert.equal(split.spoken, spoken);
assert.match(split.thinking, /这意味着/);
assert.match(split.thinking, /candidate_contrast_packet/);
assert.match(split.thinking, /不得 offer/);
assert.match(split.thinking, /第 7 条边界/);
assert.doesNotMatch(split.thinking, /发挥有没有明显失常/);
assert.equal(isRectificationProcessNarration("这意味着:方法资料已齐"), true);
assert.equal(isRectificationProcessNarration("服务器给了 candidate_contrast_packet"), true);
assert.equal(isRectificationProcessNarration(spoken), false);
const settled = settleRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`, "");
assert.equal(settled.spoken, spoken);
assert.doesNotMatch(settled.spoken, /candidate_contrast_packet/);
});
test("draft-ledger planning stays out of the spoken answer", () => {
const processTalk = [
"第二件离职事件已记录为草稿。现在账本有两件已确认加一件草稿。",
"既然实习开始和实习离职都属于同一段职业经历并且相关联,用户后面的消息会继续补充,我先向用户确认这件离职信息,同时继续收集。",
"由于离职是事务性的独立事件且 quote 路径有障碍,我继续自然访谈。",
"先确认草稿再继续收集下一件。",
"这里草稿是服务器接受但待确认状态。",
"我应该继续访谈而不是纠缠草稿确认。",
].join("\n\n");
const spoken = [
"好的,已经记下你这段职业经历:那年春天开始实习、秋天离职。加上之前上学,目前有了比较清晰的两大块时间线。",
"不用急着回忆全部。毕业后正式入职的第一份工作,大概哪一年?有没有恋爱、结婚这样的关系节点?哪一件印象深就先说哪件,大概年份就好。",
].join("\n\n");
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
assert.equal(split.spoken, spoken);
assert.match(split.thinking, /账本/);
assert.match(split.thinking, /我应该继续访谈/);
assert.match(split.thinking, /quote 路径/);
assert.doesNotMatch(split.spoken, /账本/);
assert.doesNotMatch(split.spoken, /我应该/);
assert.doesNotMatch(split.spoken, /草稿是服务器/);
assert.equal(isRectificationProcessNarration("我应该继续访谈而不是纠缠草稿确认。"), true);
assert.equal(isRectificationProcessNarration(spoken.split("\n\n")[0]!), false);
});
test("probe-deliberation with no user-facing close stays off the spoken answer", () => {
const processTalk = [
"这回应的是当前探针(感情前事,用于对照 D9 差异)。",
"当前探针是请核对一段还没用进评分的感情前事。用户给出的是一个主观感受,不是具体带日期的事件。",
"让我看看是否应该把它作为一条证据记录。",
"严格来说,用户没有给出有确定日期的新事件。根据规则,没有具体带日期事件就按 focus 处理。",
"current_question 为 null,但 current_probe 有一条。",
"也许我应该直接自然回应,不调用工具。规则要求承接。",
"我认为这里不需要写证据。写入需要日期精度。",
"我直接自然问。",
].join("\n\n");
const split = splitRectificationSpokenAndThinking(processTalk);
assert.equal(split.spoken, "");
assert.match(split.thinking, /当前探针/);
assert.match(split.thinking, /current_question/);
assert.match(split.thinking, /我直接自然问/);
assert.equal(finalizeRectificationSpokenAndThinking(processTalk).spoken, "");
const settled = settleRectificationSpokenAndThinking(processTalk, "");
assert.equal(settled.spoken, "");
assert.doesNotMatch(settled.spoken, /探针/);
assert.doesNotMatch(settled.spoken, /根据规则/);
});
test("leaked process text on the answer channel is not mixed into native thinking", () => {
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?";
assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, ""), {
thinking: processTalk,
spoken: "",
});
assert.deepEqual(settleRectificationSpokenAndThinking(spoken, processTalk), {
thinking: processTalk,
spoken,
});
assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, processTalk), {
thinking: processTalk,
spoken: "",
});
});