52477306cc
Choice path already wrote spokenFollowupForUser into the body; free-text dropped that stem and only filled empty answers, so a new collect_spoken focus stayed invisible after “记下了”. Co-authored-by: Cursor <cursoragent@cursor.com>
699 lines
28 KiB
TypeScript
699 lines
28 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { parseRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/choice-card.ts";
|
|
import {
|
|
isRenderableChoiceOpenQuestion,
|
|
openQuestionFromPersistedFocus,
|
|
persistServerOwnedFocus,
|
|
stableFollowupQuestionId,
|
|
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
|
import { emptyAnswerCollectSpokenFallback, projectTurnDecision, collectSpokenPromptForNewFocus, composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
|
import { persistCollectSpokenAssistantIfNew, persistEmptyCollectSpokenAssistant } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
|
import { spokenCollectFallbackFollowup, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
|
import { isSafeCollectSpokenPrompt } from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
|
|
import { openQuestionPromptFromToolResult } from "../src/lib/rectification-agentic/v9/turn-narration.ts";
|
|
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
|
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
|
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
|
|
import {
|
|
CASE_ID,
|
|
EVIDENCE_ID,
|
|
FOCUS_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
activeFocusFixture,
|
|
candidateSnapshotFixture,
|
|
conversationSummaryFixture,
|
|
dossierFixture,
|
|
fakeAccounting,
|
|
receiptHandlers,
|
|
} from "./rectification-v9-test-support.ts";
|
|
import { parseV9CaseDossier as parseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
|
|
|
const RELATIONSHIP_PROMPT = "还记得别的带年份的感情变化吗?比如开始认真交往、分手或结婚。";
|
|
|
|
const COLLECT_GET_QUESTION = {
|
|
question_id: "active_focus:active_focus",
|
|
kind: "collect_spoken",
|
|
intent: "collect_method_evidence",
|
|
domain: "relationship",
|
|
prompt: RELATIONSHIP_PROMPT,
|
|
};
|
|
|
|
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
|
|
return {
|
|
method_id: "active_focus",
|
|
intent: "collect_method_evidence",
|
|
ask_theme: "active_focus",
|
|
domain: "relationship",
|
|
kind_hint: null,
|
|
user_prompt_hint: "collect",
|
|
must_not_label: false,
|
|
choice_frame: null,
|
|
source: "active_focus",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function collectPersistResult(overrides: {
|
|
questionId?: string;
|
|
prompt?: string;
|
|
schema?: Record<string, unknown>;
|
|
domain?: string | null;
|
|
} = {}) {
|
|
const questionId = overrides.questionId ?? "collect:relationship:collect_method_evidence";
|
|
const prompt = overrides.prompt ?? RELATIONSHIP_PROMPT;
|
|
return {
|
|
status: "created" as const,
|
|
questionId,
|
|
prompt,
|
|
focus: {
|
|
id: FOCUS_ID,
|
|
caseId: CASE_ID,
|
|
questionId,
|
|
intent: "collect_method_evidence",
|
|
targetEvidenceId: null,
|
|
targetDomain: overrides.domain ?? "relationship",
|
|
targetKind: null,
|
|
expectedAnswerSchema: overrides.schema ?? {
|
|
prompt,
|
|
collect: true,
|
|
},
|
|
status: "active" as const,
|
|
askedAt: "2026-08-30T00:00:00.000Z",
|
|
resolvedAt: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
test("skill version stays 10.0.13 for the spoken-collect visibility fix", () => {
|
|
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
|
|
});
|
|
|
|
test("GET collect_spoken is not rendered as a standalone chat block", () => {
|
|
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
|
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
|
const choiceCard = readFileSync(new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url), "utf8");
|
|
assert.doesNotMatch(chat, /parseRectificationSpokenCollect/);
|
|
assert.doesNotMatch(chat, /showLiveSpokenCollect/);
|
|
assert.doesNotMatch(chat, /spokenCollect/);
|
|
assert.doesNotMatch(chat, /liveSpoken/);
|
|
assert.doesNotMatch(chat, /rectification-spoken-collect-prompt/);
|
|
assert.doesNotMatch(chat, /aria-label="口述采集题"/);
|
|
assert.doesNotMatch(styles, /rectification-spoken-collect-prompt/);
|
|
assert.doesNotMatch(choiceCard, /parseRectificationSpokenCollect|RectificationSpokenCollect/);
|
|
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
|
|
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
|
|
});
|
|
|
|
test("choice_card still renders through the existing choice-card branch", () => {
|
|
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
|
const render = chat.slice(
|
|
chat.indexOf("{messages.map((message) => {"),
|
|
chat.indexOf("{savedTime &&"),
|
|
);
|
|
assert.match(render, /showLiveChoiceCard/);
|
|
assert.match(render, /<RectificationChoiceCard/);
|
|
assert.match(render, /onSelect=\{submitChoice\}/);
|
|
assert.match(render, /onStop=\{submitStop\}/);
|
|
assert.doesNotMatch(render, /liveSpoken/);
|
|
assert.doesNotMatch(render, /showLiveSpokenCollect/);
|
|
});
|
|
|
|
test("empty answerText with a collect_spoken focus falls back to the focus prompt; non-empty does not", () => {
|
|
const collect = {
|
|
kind: "collect_spoken" as const,
|
|
prompt: RELATIONSHIP_PROMPT,
|
|
};
|
|
assert.equal(emptyAnswerCollectSpokenFallback("", collect), RELATIONSHIP_PROMPT);
|
|
assert.equal(emptyAnswerCollectSpokenFallback(" ", collect), RELATIONSHIP_PROMPT);
|
|
assert.equal(emptyAnswerCollectSpokenFallback("记下了。", collect), null);
|
|
assert.equal(emptyAnswerCollectSpokenFallback("", {
|
|
kind: "choice",
|
|
prompt: RELATIONSHIP_PROMPT,
|
|
}), null);
|
|
assert.equal(emptyAnswerCollectSpokenFallback("", null), null);
|
|
assert.equal(emptyAnswerCollectSpokenFallback("", {
|
|
kind: "collect_spoken",
|
|
prompt: " ",
|
|
}), null);
|
|
});
|
|
|
|
test("openQuestionFromPersistedFocus returns collect_spoken without making collect choice-ready", () => {
|
|
const collect = openQuestionFromPersistedFocus(collectPersistResult());
|
|
assert.equal(collect?.kind, "collect_spoken");
|
|
assert.equal(collect?.prompt, RELATIONSHIP_PROMPT);
|
|
assert.equal(collect?.unrenderable, undefined);
|
|
assert.equal(isRenderableChoiceOpenQuestion(collect), false);
|
|
|
|
const choiceFocus = {
|
|
status: "created" as const,
|
|
questionId: "probe:education.2016",
|
|
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
|
|
focus: {
|
|
id: FOCUS_ID,
|
|
caseId: CASE_ID,
|
|
questionId: "probe:education.2016",
|
|
intent: "distinguish_candidates",
|
|
targetEvidenceId: null,
|
|
targetDomain: "education",
|
|
targetKind: null,
|
|
expectedAnswerSchema: {
|
|
choice: {
|
|
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
|
|
option_a: "明确发生且时间吻合",
|
|
option_b: "发生过但程度较弱",
|
|
option_c: "明确没有发生",
|
|
option_d: "这段记不清楚",
|
|
options: [
|
|
{ key: "A", label: "明确发生且时间吻合", answer_class: "yes" },
|
|
{ key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" },
|
|
{ key: "C", label: "明确没有发生", answer_class: "no" },
|
|
{ key: "D", label: "这段记不清楚", answer_class: "unsure" },
|
|
],
|
|
},
|
|
},
|
|
status: "active" as const,
|
|
askedAt: "2026-08-30T00:00:00.000Z",
|
|
resolvedAt: null,
|
|
},
|
|
};
|
|
const choice = openQuestionFromPersistedFocus(choiceFocus);
|
|
assert.equal(choice?.kind, "choice");
|
|
assert.equal(choice?.prompt, choiceFocus.prompt);
|
|
assert.notEqual(choice?.unrenderable, true);
|
|
assert.equal(isRenderableChoiceOpenQuestion(choice), true);
|
|
|
|
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
|
const nextInterview = answerChoice.slice(
|
|
answerChoice.indexOf("export async function persistNextInterviewAfterChoice"),
|
|
answerChoice.indexOf("async function persistFocusAfterChoice"),
|
|
);
|
|
assert.match(nextInterview, /isRenderableChoiceOpenQuestion/);
|
|
assert.match(nextInterview, /接下来请点选下面这一问/);
|
|
assert.ok(nextInterview.indexOf("isRenderableChoiceOpenQuestion") < nextInterview.indexOf("接下来请点选下面这一问"));
|
|
|
|
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
|
assert.match(route, /isRenderableChoiceOpenQuestion/);
|
|
const fastPath = route.slice(
|
|
route.indexOf('if (action === "message")'),
|
|
route.indexOf("const requestTime"),
|
|
);
|
|
assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/);
|
|
});
|
|
|
|
test("collect focus keeps current_probe and inference.next_probe null; choice focus does not", () => {
|
|
const collectDossier = parseDossier(dossierFixture({
|
|
latestResult: candidateSnapshotFixture({
|
|
decisionReceipt: { inference_state: { next_probe: { id: "should-hide" } } },
|
|
}),
|
|
conversationSummary: conversationSummaryFixture({
|
|
activeFocus: activeFocusFixture({
|
|
intent: "collect_method_evidence",
|
|
targetDomain: "relationship",
|
|
expectedAnswerSchema: {
|
|
prompt: RELATIONSHIP_PROMPT,
|
|
collect: true,
|
|
},
|
|
}),
|
|
}),
|
|
}));
|
|
assert.ok(collectDossier);
|
|
const collectProjection = projectTurnDecision(collectDossier);
|
|
assert.equal((collectProjection.current_question as { kind?: string } | null)?.kind, "collect_spoken");
|
|
assert.equal(collectProjection.current_probe ?? null, null);
|
|
assert.equal((collectProjection.inference as { next_probe?: unknown } | null)?.next_probe ?? null, null);
|
|
|
|
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
|
|
const projection = tools.slice(
|
|
tools.indexOf("function agentVisibleLatestProjection"),
|
|
tools.indexOf("question_contract: {"),
|
|
);
|
|
assert.match(projection, /kind:\s*extras\.openQuestion\.kind|kind: extras\.openQuestion\.kind/);
|
|
assert.match(projection, /kind === "choice"/);
|
|
assert.match(projection, /next_probe: null/);
|
|
assert.match(projection, /current_probe: null/);
|
|
});
|
|
|
|
test("active_focus collect followups get a domain-stable questionId, not active_focus:active_focus", async () => {
|
|
const followup = collectFollowup();
|
|
assert.equal(stableFollowupQuestionId(followup).includes("active_focus:active_focus"), false);
|
|
assert.match(stableFollowupQuestionId(followup), /^collect:relationship:collect_method_evidence$/);
|
|
const degraded = spokenCollectFallbackFollowup({
|
|
...followup,
|
|
method_id: "dasha_events",
|
|
ask_theme: "dated_event",
|
|
domain: "education",
|
|
semantic_key: "education:2016",
|
|
probe_year: 2016,
|
|
});
|
|
assert.equal(stableFollowupQuestionId(degraded).includes("education:2016"), false);
|
|
assert.match(stableFollowupQuestionId(degraded), /^collect:education:collect_method_evidence$/);
|
|
|
|
const accounting = fakeAccounting({
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
id: 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-30T00:00:00.000Z",
|
|
resolved_at: null,
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
const stale = {
|
|
id: FOCUS_ID,
|
|
caseId: CASE_ID,
|
|
questionId: "active_focus:active_focus",
|
|
intent: "collect_method_evidence",
|
|
targetEvidenceId: null,
|
|
targetDomain: "relationship",
|
|
targetKind: null,
|
|
expectedAnswerSchema: {
|
|
prompt: RELATIONSHIP_PROMPT,
|
|
collect: true,
|
|
},
|
|
status: "active" as const,
|
|
askedAt: "2026-08-30T00:00:00.000Z",
|
|
resolvedAt: null,
|
|
};
|
|
const persisted = await persistServerOwnedFocus({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
activeFocus: stale,
|
|
decisionReceipt: null,
|
|
followup,
|
|
});
|
|
assert.equal(persisted.status, "created");
|
|
assert.notEqual(persisted.questionId, "active_focus:active_focus");
|
|
assert.match(persisted.questionId ?? "", /^collect:relationship:collect_method_evidence$/);
|
|
assert.equal(persisted.status, "created");
|
|
const open = openQuestionFromPersistedFocus(persisted);
|
|
assert.equal(open?.kind, "collect_spoken");
|
|
});
|
|
|
|
test("agent prompt does not let the model write a persisted question stem", () => {
|
|
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
|
|
const prompt = agent.slice(
|
|
agent.indexOf("const agenticRectificationInstructions"),
|
|
agent.indexOf("export function getRectificationV9Agent"),
|
|
);
|
|
assert.match(prompt, /有持久化当前问题时/);
|
|
assert.match(prompt, /题干一律不由你写/);
|
|
assert.match(prompt, /collect_spoken[\s\S]*服务器接在正文之后/);
|
|
assert.match(prompt, /没有持久化当前问题时,用自然语言问一件带大概年份的经历/);
|
|
assert.match(prompt, /请点选/);
|
|
assert.match(prompt, /选择卡/);
|
|
assert.doesNotMatch(prompt, /必须由你用自己的话在正文里问出来/);
|
|
assert.doesNotMatch(prompt, /界面提示条/);
|
|
});
|
|
|
|
const FALLBACK_REQUEST_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
const RECORDED_BODY = "记下了:2022年搬家。";
|
|
const COLLECT_QUESTION_ID = "collect:relationship:collect_method_evidence";
|
|
|
|
function collectFocus() {
|
|
return activeFocusFixture({
|
|
intent: "collect_method_evidence",
|
|
targetDomain: "relationship",
|
|
questionId: COLLECT_QUESTION_ID,
|
|
expectedAnswerSchema: {
|
|
prompt: RELATIONSHIP_PROMPT,
|
|
collect: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
function collectFocusDossier() {
|
|
return dossierFixture({
|
|
conversationSummary: conversationSummaryFixture({
|
|
activeFocus: collectFocus(),
|
|
}),
|
|
});
|
|
}
|
|
|
|
function relocation2022Dossier(activeFocus: ReturnType<typeof collectFocus> | null) {
|
|
return dossierFixture({
|
|
evidence: [
|
|
{
|
|
id: EVIDENCE_ID,
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "relocation",
|
|
domain: "relocation",
|
|
occurred_from: "2022-01-01",
|
|
occurred_to: null,
|
|
date_precision: "year",
|
|
summary: "2022年搬家",
|
|
status: "confirmed",
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-08-30T00:00:00.000Z",
|
|
},
|
|
],
|
|
conversationSummary: conversationSummaryFixture({
|
|
activeFocus,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function choiceFocusDossier() {
|
|
return dossierFixture({
|
|
conversationSummary: conversationSummaryFixture({
|
|
activeFocus: activeFocusFixture({
|
|
intent: "distinguish_candidates",
|
|
targetDomain: "education",
|
|
expectedAnswerSchema: {
|
|
choice: {
|
|
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
|
|
option_a: "明确发生且时间吻合",
|
|
option_b: "发生过但程度较弱",
|
|
option_c: "明确没有发生",
|
|
option_d: "这段记不清楚",
|
|
options: [
|
|
{ key: "A", label: "明确发生且时间吻合", answer_class: "yes" },
|
|
{ key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" },
|
|
{ key: "C", label: "明确没有发生", answer_class: "no" },
|
|
{ key: "D", label: "这段记不清楚", answer_class: "unsure" },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
test("new collect_spoken focus is visible from the focus lifecycle, not from body text", () => {
|
|
const focus = {
|
|
id: FOCUS_ID,
|
|
questionId: COLLECT_QUESTION_ID,
|
|
intent: "collect_method_evidence",
|
|
targetDomain: "relationship",
|
|
expectedAnswerSchema: {
|
|
prompt: RELATIONSHIP_PROMPT,
|
|
collect: true,
|
|
},
|
|
};
|
|
assert.equal(
|
|
collectSpokenPromptForNewFocus({ previousFocusId: null, focus }),
|
|
RELATIONSHIP_PROMPT,
|
|
);
|
|
assert.equal(
|
|
collectSpokenPromptForNewFocus({ previousFocusId: FOCUS_ID, focus }),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
collectSpokenPromptForNewFocus({
|
|
previousFocusId: null,
|
|
focus: {
|
|
id: FOCUS_ID,
|
|
intent: "distinguish_candidates",
|
|
expectedAnswerSchema: {
|
|
choice: { prompt: RELATIONSHIP_PROMPT, option_a: "A", option_b: "B", option_c: "C", option_d: "D" },
|
|
},
|
|
},
|
|
}),
|
|
null,
|
|
);
|
|
assert.equal(composeCollectSpokenAssistantText(" ", RELATIONSHIP_PROMPT).composed, RELATIONSHIP_PROMPT);
|
|
assert.equal(
|
|
composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).composed,
|
|
`${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`,
|
|
);
|
|
assert.equal(
|
|
composeCollectSpokenAssistantText(RECORDED_BODY, RELATIONSHIP_PROMPT).delta,
|
|
`\n\n${RELATIONSHIP_PROMPT}`,
|
|
);
|
|
assert.equal(spokenFollowupForUser(collectFollowup()), RELATIONSHIP_PROMPT);
|
|
assert.equal(isSafeCollectSpokenPrompt(RELATIONSHIP_PROMPT), true);
|
|
assert.equal(isSafeCollectSpokenPrompt("接下来请点选下面这一问。"), false);
|
|
assert.equal(isSafeCollectSpokenPrompt("看下面这一问"), false);
|
|
assert.equal(isSafeCollectSpokenPrompt("请继续 focusId 提问"), false);
|
|
assert.equal(openQuestionPromptFromToolResult({
|
|
type: "tool-result",
|
|
payload: {
|
|
result: { open_question: { kind: "collect_spoken", prompt: RELATIONSHIP_PROMPT } },
|
|
},
|
|
}), null);
|
|
});
|
|
|
|
test("empty agent body persists the collect_spoken prompt as a plain assistant message", async () => {
|
|
const accounting = fakeAccounting({
|
|
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
|
|
append_agentic_rectification_turn: (_fn, args) => ({
|
|
turn_id: "99999999-9999-4999-8999-999999999999",
|
|
idempotent: false,
|
|
assistant_message: args.p_assistant_message,
|
|
}),
|
|
});
|
|
const filled = await persistEmptyCollectSpokenAssistant({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
requestId: FALLBACK_REQUEST_ID,
|
|
answerText: " ",
|
|
previousFocusId: null,
|
|
});
|
|
assert.equal(filled, RELATIONSHIP_PROMPT);
|
|
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
|
assert.equal(turn?.args.p_assistant_message, RELATIONSHIP_PROMPT);
|
|
assert.equal(turn?.args.p_user_message, null);
|
|
assert.equal(turn?.args.p_status, "completed");
|
|
|
|
const appended = await persistCollectSpokenAssistantIfNew({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
requestId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
answerText: RECORDED_BODY,
|
|
previousFocusId: null,
|
|
});
|
|
assert.equal(appended, `\n\n${RELATIONSHIP_PROMPT}`);
|
|
const turns = accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn");
|
|
assert.equal(turns.length, 2);
|
|
assert.equal(turns[1]?.args.p_assistant_message, RELATIONSHIP_PROMPT);
|
|
assert.match(String(turns[1]?.args.p_assistant_message), /还记得别的带年份的感情变化吗/);
|
|
assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /请点选/);
|
|
assert.doesNotMatch(String(turns[1]?.args.p_assistant_message), /focusId|current_question|choice_frame/);
|
|
});
|
|
|
|
test("the same open collect focus is not appended again on the next turn", async () => {
|
|
const accounting = fakeAccounting({
|
|
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
|
});
|
|
const skipped = await persistCollectSpokenAssistantIfNew({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
requestId: FALLBACK_REQUEST_ID,
|
|
answerText: RECORDED_BODY,
|
|
previousFocusId: FOCUS_ID,
|
|
});
|
|
assert.equal(skipped, null);
|
|
assert.equal(
|
|
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("choice focus never copies the stem into the assistant body", async () => {
|
|
const accounting = fakeAccounting({
|
|
get_agentic_rectification_case_dossier: () => choiceFocusDossier(),
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
|
});
|
|
const skipped = await persistCollectSpokenAssistantIfNew({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
requestId: FALLBACK_REQUEST_ID,
|
|
answerText: "记下了。",
|
|
previousFocusId: null,
|
|
});
|
|
assert.equal(skipped, null);
|
|
assert.equal(
|
|
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("in-turn persistPlanFocus and idle persist share one collect stem and emit once", async () => {
|
|
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
|
const idle = answerChoice.slice(
|
|
answerChoice.indexOf("export async function persistNextInterviewIfIdle"),
|
|
answerChoice.indexOf("export async function persistCollectSpokenAssistantIfNew"),
|
|
);
|
|
assert.match(idle, /if \(dossier\.conversationSummary\.activeFocus\)/);
|
|
assert.match(idle, /hostNarration:\s*nextInterview\.hostNarration/);
|
|
assert.match(answerChoice, /spokenFollowupForUser\(followup\)/);
|
|
|
|
const accounting = fakeAccounting({
|
|
get_agentic_rectification_case_dossier: () => collectFocusDossier(),
|
|
append_agentic_rectification_turn: (_fn, args) => ({
|
|
turn_id: TURN_ID,
|
|
assistant_message: args.p_assistant_message,
|
|
}),
|
|
});
|
|
const first = await persistCollectSpokenAssistantIfNew({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
requestId: FALLBACK_REQUEST_ID,
|
|
answerText: RECORDED_BODY,
|
|
previousFocusId: null,
|
|
hostNarration: spokenFollowupForUser(collectFollowup()),
|
|
});
|
|
assert.equal(first, `\n\n${RELATIONSHIP_PROMPT}`);
|
|
const second = await persistCollectSpokenAssistantIfNew({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
|
answerText: RECORDED_BODY,
|
|
previousFocusId: null,
|
|
alreadyEmitted: true,
|
|
hostNarration: spokenFollowupForUser(collectFollowup()),
|
|
});
|
|
assert.equal(second, null);
|
|
assert.equal(
|
|
accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("agent route appends a new collect_spoken stem from focus lifecycle, not body text", () => {
|
|
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
|
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
|
|
assert.match(afterRun, /persistNextInterviewIfIdle/);
|
|
assert.match(afterRun, /persistCollectSpokenAssistantIfNew/);
|
|
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
|
|
assert.ok(afterRun.indexOf("persistNextInterviewIfIdle") < afterRun.indexOf("persistCollectSpokenAssistantIfNew"));
|
|
assert.match(afterRun, /previousFocusId:\s*result\.previousFocusId/);
|
|
assert.match(afterRun, /alreadyEmitted:\s*result\.collectSpokenEmitted/);
|
|
assert.match(afterRun, /hostNarration:\s*idleHostNarration/);
|
|
assert.match(afterRun, /send\(\{ type: "answer\.delta", text: fallback \}\)/);
|
|
assert.doesNotMatch(afterRun, /if \(!result\.answerText\.trim\(\)\)/);
|
|
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
|
|
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
|
assert.match(agentRun, /collectSpokenPromptForNewFocus/);
|
|
assert.match(agentRun, /composeCollectSpokenAssistantText/);
|
|
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
|
|
});
|
|
|
|
async function runCollectTurn(input: {
|
|
firstDossier: unknown;
|
|
laterDossier: unknown;
|
|
spoken?: string;
|
|
}) {
|
|
const emitted: Array<{ type: string; text?: string }> = [];
|
|
let loads = 0;
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => {
|
|
loads += 1;
|
|
return loads === 1 ? input.firstDossier : input.laterDossier;
|
|
},
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
|
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
|
});
|
|
const result = await runV9AgentTurn({
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: "22222222-2222-4222-8222-222222222222",
|
|
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
|
action: "evidence",
|
|
message: "2022年搬家",
|
|
modelName: "gpt-4o-mini",
|
|
accounting: accounting.client,
|
|
billing: {
|
|
reserve: async () => ({ success: true, status: 200 }),
|
|
complete: async () => true,
|
|
release: async () => true,
|
|
},
|
|
emit: (event) => { emitted.push(event); },
|
|
buildAgent: async () => ({
|
|
stream: async () => ({
|
|
fullStream: (async function* () {
|
|
yield { type: "start" };
|
|
yield { type: "tool-call", payload: { toolName: "skill", args: { name: "jyotish-birth-time-rectification" } } };
|
|
yield { type: "tool-result", payload: { toolName: "skill" } };
|
|
yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } };
|
|
yield { type: "tool-result", payload: { toolName: "rectification-read-case" } };
|
|
if (input.spoken) {
|
|
yield { type: "text-delta", payload: { text: input.spoken } };
|
|
}
|
|
yield { type: "finish" };
|
|
})(),
|
|
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 4 }),
|
|
}),
|
|
getSkill: async () => ({ name: "jyotish-birth-time-rectification", instructions: "skill" }),
|
|
}) as never,
|
|
});
|
|
return { result, emitted };
|
|
}
|
|
|
|
test("free-text 2022 relocation creates a new collect_spoken stem in the assistant body", async () => {
|
|
const { result, emitted } = await runCollectTurn({
|
|
firstDossier: relocation2022Dossier(null),
|
|
laterDossier: relocation2022Dossier(collectFocus()),
|
|
spoken: RECORDED_BODY,
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.previousFocusId, null);
|
|
assert.equal(result.collectSpokenEmitted, true);
|
|
assert.equal(result.answerText, `${RECORDED_BODY}\n\n${RELATIONSHIP_PROMPT}`);
|
|
assert.match(result.answerText, /还记得别的带年份的感情变化吗/);
|
|
const deltas = emitted.filter((event) => event.type === "answer.delta").map((event) => event.text);
|
|
assert.ok(deltas.includes(RECORDED_BODY));
|
|
assert.ok(deltas.includes(`\n\n${RELATIONSHIP_PROMPT}`));
|
|
assert.doesNotMatch(result.answerText, /请点选/);
|
|
assert.doesNotMatch(result.answerText, /focusId|current_question|choice_frame/);
|
|
});
|
|
|
|
test("empty stream with a newly created collect_spoken focus uses the focus prompt as the assistant message", async () => {
|
|
const { result, emitted } = await runCollectTurn({
|
|
firstDossier: relocation2022Dossier(null),
|
|
laterDossier: relocation2022Dossier(collectFocus()),
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.answerText, RELATIONSHIP_PROMPT);
|
|
assert.equal(result.collectSpokenEmitted, true);
|
|
assert.deepEqual(
|
|
emitted.filter((event) => event.type === "answer.delta"),
|
|
[{ type: "answer.delta", text: RELATIONSHIP_PROMPT }],
|
|
);
|
|
});
|
|
|
|
test("an already open collect_spoken focus is not appended again after a later turn", async () => {
|
|
const { result } = await runCollectTurn({
|
|
firstDossier: collectFocusDossier(),
|
|
laterDossier: collectFocusDossier(),
|
|
spoken: RECORDED_BODY,
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.previousFocusId, FOCUS_ID);
|
|
assert.equal(result.collectSpokenEmitted, false);
|
|
assert.equal(result.answerText, RECORDED_BODY);
|
|
assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/);
|
|
});
|
|
|
|
test("choice focus leaves the stem on the card and out of the body", async () => {
|
|
const { result } = await runCollectTurn({
|
|
firstDossier: choiceFocusDossier(),
|
|
laterDossier: choiceFocusDossier(),
|
|
spoken: RECORDED_BODY,
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.collectSpokenEmitted, false);
|
|
assert.doesNotMatch(result.answerText, /升学结果或学习环境/);
|
|
assert.doesNotMatch(result.answerText, /还记得别的带年份的感情变化吗/);
|
|
assert.doesNotMatch(result.answerText, /请点选/);
|
|
});
|