fix(rectification): bind collect-denial follow-up to the asked turn
Saying no to a spoken collect left the next stem only on current_question, so the preparing spinner never cleared. Persist the ack, link asked_turn_id, and emit run.completed with turnId (BUG-525). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8097,3 +8097,20 @@
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-525 | 采集题答「没有」后「正在准备下一个问题…」不消失
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-04
|
||||
- 最近更新:2026-09-04
|
||||
- 影响面:`applyCollectFocusDenial`、`persistCollectDenialTurn`、`/api/rectification/agent` `completedMessageResponse`、`rectification-agentic-chat` 问题缺口重试
|
||||
- 用户现象:采集口述题答「没有」后,助手气泡已经给出下一问,底部仍转圈显示「正在准备下一个问题…」,不会变成「没有拿到下一个问题」。范围条「目前范围 … 还在收窄」是采集阶段的只读提示,不是本缺陷。
|
||||
- 触发条件:当前焦点为 `collect_spoken`;意图分类 `answer_current_focus` + `answer_class: "no"`;走采集拒答快路径(不进 Agent)。
|
||||
- 根因:拒答快路径把下一问题干整段当成正文流出去,但 turn 上 `question` 为空、新焦点没有 `asked_turn_id`,`run.completed` 也不带 `turnId`。前端 `liveQuestionOnMessages` 要求已结算消息的 `question.focus_id` 等于 `current_question.focus_id`,缺口因此一直是 `preparing`。`applyCaseSnapshot` 只要快照里有 `current_question` 就把重试次数清零,定时重拉永远到不了 `unavailable`。
|
||||
- 修复:拒答后先落确定性 turn,再 `linkFocusAskedTurn`。库内正文为确认句 + 精确题干后缀(GET 仍按 `asked_turn_id` detach);直播只推确认句。`run.completed` 带上 `turnId`。快照里出现 `current_question` 不再重置缺口重试。
|
||||
- 验证:`rectification-collect-stall` 锁 family collect 拒答 → occupation 题干写入 turn、`p_asked_turn_id`、直播确认句;route 源码锁 `persistCollectDenialTurn` 与 `finished.turnId`。`rectification-surface-contract` 禁止 `if (nextQuestion !== null) setQuestionRetryAttempts(0)`。
|
||||
- 防复发:采集拒答快路径建立的下一焦点必须有 `asked_turn_id`,且 `run.completed` 必须带该 turn。不得把「快照已有 current_question」当成缺口已闭合。直播不得把下一问题干当作整段回复(题干走 turn.question)。
|
||||
- 相关记录:BUG-440、BUG-490、BUG-491、BUG-505、BUG-520
|
||||
- 复发自:BUG-491
|
||||
- 修复版本:待发布
|
||||
- 编号说明:rebase 到 `origin/staging` 时 BUG-524 已被 consultation_workflow 占用,本条落在 BUG-525。
|
||||
|
||||
@@ -13,6 +13,7 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
|
||||
import {
|
||||
applyRectificationChoice,
|
||||
applyCollectFocusDenial,
|
||||
persistCollectDenialTurn,
|
||||
persistNextInterviewIfIdle,
|
||||
} from "@/lib/rectification-agentic/v9/answer-choice";
|
||||
import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent";
|
||||
@@ -52,10 +53,18 @@ import {
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 240;
|
||||
|
||||
function completedMessageResponse(text: string, requestId: string, caseId: string) {
|
||||
function completedMessageResponse(
|
||||
text: string,
|
||||
requestId: string,
|
||||
caseId: string,
|
||||
turnId?: string | null,
|
||||
) {
|
||||
const completed = turnId
|
||||
? { type: "run.completed", turnId }
|
||||
: { type: "run.completed" };
|
||||
const body = [
|
||||
JSON.stringify({ type: "answer.delta", text }),
|
||||
JSON.stringify({ type: "run.completed" }),
|
||||
JSON.stringify(completed),
|
||||
"",
|
||||
].join("\n");
|
||||
return new Response(body, {
|
||||
@@ -363,12 +372,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
if (!classified || classified.intent === "unclear") {
|
||||
const narration = RECTIFICATION_USER_COPY.unclearFocusReply;
|
||||
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage: narration,
|
||||
});
|
||||
return completedMessageResponse(narration, requestId, caseId);
|
||||
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
|
||||
}
|
||||
if (classified.intent === "answer_current_focus") {
|
||||
if (!classified.answer_class) {
|
||||
@@ -398,7 +407,7 @@ export async function POST(request: Request) {
|
||||
narrateAdopt,
|
||||
});
|
||||
if (!continueToAgent) {
|
||||
return completedMessageResponse(applied.narration, requestId, caseId);
|
||||
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
|
||||
}
|
||||
}
|
||||
if (classified.intent === "stop_rectification") {
|
||||
@@ -419,7 +428,7 @@ export async function POST(request: Request) {
|
||||
userDisplay: parsed.data.message ?? null,
|
||||
});
|
||||
await transitionV9CaseStatus(accounting, userId, caseId, "paused");
|
||||
return completedMessageResponse(applied.narration, requestId, caseId);
|
||||
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
|
||||
}
|
||||
} else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema)) {
|
||||
let classified = null;
|
||||
@@ -443,12 +452,15 @@ export async function POST(request: Request) {
|
||||
narrateAdopt,
|
||||
});
|
||||
if (!continueToAgent) {
|
||||
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
const finished = await persistCollectDenialTurn({
|
||||
accounting,
|
||||
userId,
|
||||
caseId,
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage: applied.narration,
|
||||
applied,
|
||||
});
|
||||
return completedMessageResponse(applied.narration, requestId, caseId);
|
||||
return completedMessageResponse(finished.streamText, requestId, caseId, finished.turnId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -468,12 +480,12 @@ export async function POST(request: Request) {
|
||||
narrateAdopt,
|
||||
});
|
||||
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
|
||||
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage,
|
||||
});
|
||||
return completedMessageResponse(assistantMessage, requestId, caseId);
|
||||
return completedMessageResponse(assistantMessage, requestId, caseId, turn.turnId);
|
||||
}
|
||||
if (decision.nextAction === "ask_candidate_discriminator") {
|
||||
const catalog = rectificationFollowupCatalog(
|
||||
@@ -529,16 +541,16 @@ export async function POST(request: Request) {
|
||||
expectedRevision: previous?.revision ?? 0,
|
||||
userDisplay: parsed.data.message ?? null,
|
||||
});
|
||||
return completedMessageResponse(applied.narration, requestId, caseId);
|
||||
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
|
||||
}
|
||||
}
|
||||
const narration = RECTIFICATION_USER_COPY.choicePrompt;
|
||||
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage: narration,
|
||||
});
|
||||
return completedMessageResponse(narration, requestId, caseId);
|
||||
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
|
||||
}
|
||||
if (!plan.next_followup) {
|
||||
const idle = await persistNextInterviewIfIdle({
|
||||
@@ -548,12 +560,12 @@ export async function POST(request: Request) {
|
||||
narrateAdopt,
|
||||
});
|
||||
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
|
||||
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId,
|
||||
userMessage: parsed.data.message ?? null,
|
||||
assistantMessage,
|
||||
});
|
||||
return completedMessageResponse(assistantMessage, requestId, caseId);
|
||||
return completedMessageResponse(assistantMessage, requestId, caseId, turn.turnId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,7 +645,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
setCandidateResult(nextCandidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
if (nextQuestion !== null) setQuestionRetryAttempts(0);
|
||||
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
||||
setChoiceCard(nextChoice);
|
||||
setCaseStatus(nextCaseStatus);
|
||||
|
||||
@@ -62,6 +62,7 @@ export const RECTIFICATION_USER_COPY = {
|
||||
adoptCue: "可以从下面选一个先用着。",
|
||||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||||
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
|
||||
collectDeclinedAck: "记下了,这方面先跳过。",
|
||||
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
|
||||
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
|
||||
} as const;
|
||||
@@ -216,6 +217,7 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.adoptCue,
|
||||
RECTIFICATION_USER_COPY.hostNarrationFallback,
|
||||
RECTIFICATION_USER_COPY.continueCollectFallback,
|
||||
RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||||
RECTIFICATION_USER_COPY.tiedFirstStop,
|
||||
...Object.values(USER_COLLECT_QUESTION),
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
type AdoptNarrationWriter,
|
||||
} from "./adopt-narration.ts";
|
||||
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
|
||||
import { composeCollectSpokenAssistantText } from "./collect-prompt";
|
||||
import {
|
||||
blockingMethodsCovered,
|
||||
buildMethodFollowupPlan,
|
||||
@@ -192,6 +193,7 @@ export type AppliedChoiceReceipt = Readonly<{
|
||||
nextAction: ReturnType<typeof publicNextAction>;
|
||||
nextInterviewPersisted: boolean;
|
||||
nextChoiceReady: boolean;
|
||||
turnId: string | null;
|
||||
}>;
|
||||
|
||||
function asText(value: unknown): string | null {
|
||||
@@ -643,7 +645,12 @@ export async function applyCollectFocusDenial(
|
||||
deferFollowup?: boolean;
|
||||
narrateAdopt?: AdoptNarrationWriter;
|
||||
},
|
||||
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
|
||||
): Promise<{
|
||||
narration: string;
|
||||
nextInterviewPersisted: boolean;
|
||||
nextChoiceReady: boolean;
|
||||
focus: ConversationFocus | null;
|
||||
}> {
|
||||
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
|
||||
const focus = dossier.conversationSummary.activeFocus;
|
||||
if (!focus || focus.id !== input.focusId) {
|
||||
@@ -656,9 +663,10 @@ export async function applyCollectFocusDenial(
|
||||
});
|
||||
if (input.deferFollowup === true) {
|
||||
return {
|
||||
narration: "记下了,这方面先跳过。",
|
||||
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
nextInterviewPersisted: false,
|
||||
nextChoiceReady: false,
|
||||
focus: null,
|
||||
};
|
||||
}
|
||||
let birthDate: string | null = null;
|
||||
@@ -701,9 +709,57 @@ export async function applyCollectFocusDenial(
|
||||
narration: nextInterview.hostNarration,
|
||||
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
|
||||
nextChoiceReady: nextInterview.choiceReady,
|
||||
focus: nextInterview.focus ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type CollectDenialApplied = Awaited<ReturnType<typeof applyCollectFocusDenial>>;
|
||||
|
||||
/**
|
||||
* After a collect "没有", persist the deterministic turn then bind the next
|
||||
* focus to that turn so the stem can hang on the message. Stream only the
|
||||
* acknowledgment; the server-owned stem joins via asked_turn_id (BUG-525).
|
||||
*/
|
||||
export async function persistCollectDenialTurn(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
requestId: string;
|
||||
userMessage: string | null;
|
||||
applied: CollectDenialApplied;
|
||||
}): Promise<{ streamText: string; turnId: string }> {
|
||||
const hasNextStem = Boolean(
|
||||
input.applied.focus
|
||||
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
|
||||
&& input.applied.narration.trim(),
|
||||
);
|
||||
const stored = hasNextStem
|
||||
? composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, input.applied.narration)
|
||||
: input.applied.narration;
|
||||
const streamText = hasNextStem ? RECTIFICATION_USER_COPY.collectDeclinedAck : input.applied.narration;
|
||||
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: input.requestId,
|
||||
userMessage: input.userMessage,
|
||||
assistantMessage: stored,
|
||||
});
|
||||
if (input.applied.focus) {
|
||||
try {
|
||||
await linkFocusAskedTurn({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
focus: input.applied.focus,
|
||||
askedTurnId: turn.turnId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] link collect-denial focus to turn failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { streamText, turnId: turn.turnId };
|
||||
}
|
||||
|
||||
export function isStalePreAdoptFocus(
|
||||
acceptedTime: string | null | undefined,
|
||||
focus: { intent?: string | null } | null | undefined,
|
||||
@@ -946,6 +1002,7 @@ async function persistApplied(
|
||||
let hostNarration = input.narration;
|
||||
let skippedNextInterview = false;
|
||||
let nextFocus: ConversationFocus | null = null;
|
||||
let turnId: string | null = null;
|
||||
if (
|
||||
command.deferFollowup !== true
|
||||
&& input.userStopped !== true
|
||||
@@ -1016,6 +1073,7 @@ ${nonConvergingRangeNarration({
|
||||
});
|
||||
narrationPersisted = true;
|
||||
if (turn.turnId) {
|
||||
turnId = turn.turnId;
|
||||
try {
|
||||
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
|
||||
.conversationSummary.activeFocus;
|
||||
@@ -1071,6 +1129,7 @@ ${nonConvergingRangeNarration({
|
||||
nextAction,
|
||||
nextInterviewPersisted,
|
||||
nextChoiceReady,
|
||||
turnId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ test("delivery range narration puts the stop reason before the progress clause",
|
||||
assert.match(text, /已经从最初的 60 分钟收到 14:02–14:43 这 41 分钟/);
|
||||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
|
||||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
|
||||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectDeclinedAck));
|
||||
});
|
||||
|
||||
test("question stem ownership stays on set-focus spokenPrompt, not a slot or a second turn", () => {
|
||||
|
||||
@@ -24,12 +24,19 @@ import {
|
||||
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
|
||||
import {
|
||||
applyCollectFocusDenial,
|
||||
persistCollectDenialTurn,
|
||||
persistNextInterviewAfterChoice,
|
||||
persistNextInterviewIfIdle,
|
||||
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import {
|
||||
RECTIFICATION_USER_COPY,
|
||||
USER_COLLECT_QUESTION,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
ATTEMPT_ID,
|
||||
CASE_ID,
|
||||
FOCUS_ID,
|
||||
TURN_ID,
|
||||
@@ -642,6 +649,96 @@ test("occupation collect denial declines the focus and advances coverage to hora
|
||||
assert.equal(horary.next_followup?.domain, "horary");
|
||||
});
|
||||
|
||||
test("collect denial persists the next stem on the turn and binds asked_turn_id", async () => {
|
||||
const familyFocus = {
|
||||
id: FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: "collect:family:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
target_evidence_id: null,
|
||||
target_domain: "family",
|
||||
target_kind: null,
|
||||
expected_answer_schema: {
|
||||
prompt: "2021 年前后,家里如果有结婚、添丁或住院这类事,记得大概哪年就行。",
|
||||
collect: true,
|
||||
},
|
||||
status: "active",
|
||||
asked_at: "2026-08-29T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
};
|
||||
let loads = 0;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => {
|
||||
loads += 1;
|
||||
if (loads === 1) {
|
||||
return rpcDossier(revision5Dossier(revision5State()), familyFocus);
|
||||
}
|
||||
return rpcDossier(revision5Dossier(revision5State(), {
|
||||
declinedTopics: [{ target_domain: "family", status: "declined" }],
|
||||
}));
|
||||
},
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus_id: args.p_focus_id,
|
||||
status: args.p_status,
|
||||
evidence_id: null,
|
||||
idempotent: false,
|
||||
}),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
id: "acacacac-acac-4cac-8cac-acacacacacac",
|
||||
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-29T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
asked_turn_id: args.p_asked_turn_id ?? null,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
append_agentic_rectification_turn: () => ({
|
||||
turn_id: TURN_ID,
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const applied = await applyCollectFocusDenial(accounting.client, {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
focusId: FOCUS_ID,
|
||||
});
|
||||
assert.equal(applied.nextInterviewPersisted, true);
|
||||
assert.ok(applied.focus);
|
||||
assert.equal(applied.narration, USER_COLLECT_QUESTION.occupation);
|
||||
|
||||
const finished = await persistCollectDenialTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: ATTEMPT_ID,
|
||||
userMessage: "没有",
|
||||
applied,
|
||||
});
|
||||
assert.equal(finished.turnId, TURN_ID);
|
||||
assert.equal(finished.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
|
||||
const stored = composeCollectSpokenAssistantText(
|
||||
RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
USER_COLLECT_QUESTION.occupation,
|
||||
);
|
||||
const append = accounting.calls.find((item) => item.fn === "append_agentic_rectification_turn");
|
||||
assert.equal(append?.args.p_assistant_message, stored);
|
||||
assert.ok(accounting.calls.some((item) => (
|
||||
item.fn === "set_agentic_rectification_conversation_focus"
|
||||
&& item.args.p_asked_turn_id === TURN_ID
|
||||
)));
|
||||
});
|
||||
|
||||
test("message and opening turns persist the next followup so current_question is not null", async () => {
|
||||
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"));
|
||||
@@ -1162,10 +1259,19 @@ test("has_new_dated_event continues into the agent after applying the answer", (
|
||||
fastPath.indexOf("} else {"),
|
||||
);
|
||||
assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)"));
|
||||
assert.match(collectApply, /persistV9DeterministicTurn/);
|
||||
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn"));
|
||||
assert.match(collectApply, /persistCollectDenialTurn/);
|
||||
assert.match(collectApply, /completedMessageResponse\(finished\.streamText, requestId, caseId, finished\.turnId\)/);
|
||||
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistCollectDenialTurn"));
|
||||
assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({"));
|
||||
assert.match(route, /function completedMessageResponse\([\s\S]*?turnId\?: string \| null/);
|
||||
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
const persistDenial = answerChoice.slice(
|
||||
answerChoice.indexOf("export async function persistCollectDenialTurn"),
|
||||
answerChoice.indexOf("export function isStalePreAdoptFocus"),
|
||||
);
|
||||
assert.match(persistDenial, /composeCollectSpokenAssistantText\(RECTIFICATION_USER_COPY\.collectDeclinedAck/);
|
||||
assert.match(persistDenial, /linkFocusAskedTurn/);
|
||||
assert.match(persistDenial, /streamText = hasNextStem \? RECTIFICATION_USER_COPY\.collectDeclinedAck/);
|
||||
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
|
||||
assert.match(persistApplied, /command\.deferFollowup !== true/);
|
||||
assert.equal(shouldContinueAgentForDatedEvent({
|
||||
@@ -1250,6 +1356,8 @@ test("collect denial with a new dated event does not persist the next interview
|
||||
false,
|
||||
);
|
||||
assert.equal(applied.nextInterviewPersisted, false);
|
||||
assert.equal(applied.focus, null);
|
||||
assert.equal(applied.narration, RECTIFICATION_USER_COPY.collectDeclinedAck);
|
||||
});
|
||||
|
||||
test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => {
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
copyTextForMessage,
|
||||
parseTurnQuestion,
|
||||
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { persistCollectDenialTurn, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { RECTIFICATION_AGENT_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
||||
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
RECTIFICATION_USER_COPY,
|
||||
USER_COLLECT_QUESTION,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
FOCUS_ID,
|
||||
@@ -264,3 +269,86 @@ test("refresh rebuilds answered and live questions from asked_turn_id only", ()
|
||||
assert.equal(attached[2]?.question?.answer_option, null);
|
||||
assert.equal(attached[2]?.text, "2016 年入学记下了。");
|
||||
});
|
||||
|
||||
const DENIAL_REQUEST_ID = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
test("collect denial persists ack+stem then binds asked_turn_id; stream is ack only", async () => {
|
||||
const prompt = USER_COLLECT_QUESTION.occupation;
|
||||
const accounting = fakeAccounting({
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
...activeFocusFixture({
|
||||
questionId: String(args.p_question_id),
|
||||
intent: String(args.p_intent),
|
||||
askedTurnId: String(args.p_asked_turn_id ?? ""),
|
||||
}),
|
||||
asked_turn_id: args.p_asked_turn_id,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const result = await persistCollectDenialTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: DENIAL_REQUEST_ID,
|
||||
userMessage: "没有",
|
||||
applied: {
|
||||
narration: prompt,
|
||||
nextInterviewPersisted: true,
|
||||
nextChoiceReady: false,
|
||||
focus: {
|
||||
id: FOCUS_ID,
|
||||
caseId: CASE_ID,
|
||||
questionId: "collect:occupation:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
targetEvidenceId: null,
|
||||
targetDomain: "other",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: { prompt, collect: true },
|
||||
status: "active",
|
||||
askedAt: "2026-09-04T07:47:24.000Z",
|
||||
resolvedAt: null,
|
||||
askedTurnId: null,
|
||||
answerOption: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(result.turnId, TURN_ID);
|
||||
assert.equal(result.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
|
||||
assert.notEqual(result.streamText, prompt);
|
||||
const stored = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
||||
assert.equal(
|
||||
stored?.args.p_assistant_message,
|
||||
composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, prompt),
|
||||
);
|
||||
const linked = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
|
||||
assert.equal(linked?.args.p_asked_turn_id, TURN_ID);
|
||||
assert.ok(accounting.calls.findIndex((call) => call.fn === "append_agentic_rectification_turn")
|
||||
< accounting.calls.findIndex((call) => call.fn === "set_agentic_rectification_conversation_focus"));
|
||||
});
|
||||
|
||||
test("collect denial without a next stem still writes a turn and does not invent a focus link", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
||||
});
|
||||
const result = await persistCollectDenialTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: DENIAL_REQUEST_ID,
|
||||
userMessage: "没有",
|
||||
applied: {
|
||||
narration: RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
nextInterviewPersisted: false,
|
||||
nextChoiceReady: false,
|
||||
focus: null,
|
||||
},
|
||||
});
|
||||
assert.equal(result.streamText, RECTIFICATION_USER_COPY.collectDeclinedAck);
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,8 +80,10 @@ test("the question gap is a live row with retries, then a reload; it never tells
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
|
||||
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
|
||||
// Attempts reset in handlers (a question arriving, a turn starting), never in an effect.
|
||||
assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
|
||||
// Attempts reset when a turn starts, never because the snapshot merely names a
|
||||
// current_question. Naming a question that no settled message carries live is
|
||||
// the gap itself (BUG-525); resetting here spun "正在准备下一个问题…" forever.
|
||||
assert.doesNotMatch(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
|
||||
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
|
||||
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
|
||||
// In flow: the gap is the last entry of the transcript, after the message loop, before the saved-time line.
|
||||
|
||||
Reference in New Issue
Block a user