fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627)
Independent Staging Quality Gate / validate (push) Successful in 12m11s
Independent Staging Quality Gate / publish (push) Successful in 7m54s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 11:59:47 +08:00
co-authored by Cursor
parent 0263658477
commit d96b24c276
16 changed files with 615 additions and 76 deletions
+6
View File
@@ -24,6 +24,12 @@
填完生辰和地址后,从首页点「家庭」等主题,会先按该主题排盘再写回答,不再出现「Agent 未完成必要的方法与计算步骤,本次不会扣点」。点卡不会改你看到的那句问题。自己在输入框里问的普通问题仍可一次看多个领域。Skill 版本不变。
## 2026-09-09 — 健康题记不清后不再断在「没有下一个问题」
生时校正里健康题打字「记不清」后,不会再把同一条健康线当成没用过的盘外核对再问一遍。撞上已有焦点时也不会再静默断流。穷尽如果没有下一问、卡或门槛句,服务端会补一句可见出口。缺口按钮改成「接着问」,向服务端修复;连续两次仍接不上才提示新建一次校正。Skill 版本不变。
## 2026-09-09 — 生时校正拿掉「没有 / 记不清」按钮
口述采集不再出示「没有」「记不清」快捷按钮。想跳过仍可在输入框打这两句,语义不变。选择题卡选项不动。Skill 版本不变。
+32
View File
@@ -9716,6 +9716,38 @@
- 复发自:无
- 修复版本:待发布
## BUG-626 | 跳过的健康线在 holdout 里被当成没用过,撞同 id 焦点后静默断流
- 状态:resolved
- 首次发现:2026-09-09
- 最近更新:2026-09-09
- 影响面:`holdoutFollowupFor``persistNextInterviewAfterChoice``persistExhaustionCollect`
- 用户现象:健康采集点「记不清」后再答完职业,界面只剩「没有拿到下一个问题。」
- 触发条件:七领域采集接近穷尽,健康焦点 `target_domain=health``skipped`,盘外提示仍是 `health_pressure`
- 根因:BUG-590 只给 occupied 归并 health / health_pressureholdout 的 declined 也曾漏过这一层;即便归并后,`duplicate_focus` 仍可能落到空旁白。DB 约束只有 `health`,计划层用 `health_pressure`
- 修复:holdout 的 declined 与 occupied 一样归并 health / health_pressure。`duplicate_focus``rectification_focus_duplicate` 并走 `persistExhaustionCollect`;穷尽旁白为空时用 `noCandidatesGate` 兜底。
- 验证:`frontend/tests/rectification-collect-direction-20260904.test.ts``frontend/tests/rectification-skipped-health-deadend-20260909.test.ts`
- 防复发:跳过的健康线本会话不得再作 holdout;`duplicate_focus` 必须留下可见载体。
- 相关记录:BUG-558、BUG-590、BUG-591、BUG-605
- 复发自:BUG-590 决策 4occupied 已归并,declined 曾漏)
- 修复版本:待发布
## BUG-627 | 出口闸门把穷尽当成已交付,断流后不修复
- 状态:resolved
- 首次发现:2026-09-09
- 最近更新:2026-09-09
- 影响面:`inspectNonTerminalTurnExit``ensureNonTerminalTurnExit``agent-run` idle catch、`POST /api/rectification/cases/[caseId]/repair-exit`
- 用户现象:穷尽后没有任何问题、卡或句子落库,闸门仍放行;「重新加载」只重取空快照。
- 触发条件:`exhausted` 为真,无活动焦点、未采用、无 terminalNote host turn。
- 根因:`inspectNonTerminalTurnExit.satisfied``exhausted`。穷尽只是状态,不是用户能看见的出口。idle 异常被 `agent-run` 吞掉后也到不了闸门。
- 修复:satisfied 只认活动问题 / 已采用 / 已确认 / 已落库的 terminalNote host turn / `publicCanAdopt`。无载体时 `ensureNonTerminalTurnExit` 写出门槛 host turn。idle 失败后仍调用闸门。缺口按钮改调 `repair-exit`,文案「接着问」;连续两次仍无载体才提示新建。
- 验证:`frontend/tests/rectification-exhaustion-exit-20260906.test.ts``frontend/tests/rectification-skipped-health-deadend-20260909.test.ts`
- 防复发:穷尽不得单独满足出口闸门;无可见载体必须写出 host turn。
- 相关记录:BUG-558、BUG-626
- 复发自:BUG-558 防复发条款被闸门绕过
- 修复版本:待发布
## BUG-628 | 口述采集「没有 / 记不清」按钮被用户差评
- 状态:resolved
@@ -0,0 +1,39 @@
# PROGRESS · 跳过健康后 holdout 断流 + 出口闸门(2026-09-09
工作树:`.worktrees/rectification-skipped-health-deadend-20260909`
分支:`codex/rectification-skipped-health-deadend-20260909`
任务书:`docs/tasks/TASK-rectification-skipped-health-deadend-20260909.md`
基线:开工时 `origin/staging` @ `4eea9c10`
## 开工回执
- 目标:BUG-626 跳过的健康线不再进 holdout,`duplicate_focus` 必须留下可见载体;BUG-627 出口闸门不再把穷尽当成已交付;缺口按钮改为服务端修复。
- 编号:BUG-628 已被「没有 / 记不清」按钮占用,本单只用 626/627。
- 决策 1 在基线里已有 `declinedHealth` 特判;本单改成与 occupied 一样把两个别名写入 blocked 集合,并补 skipped `health` 回归。
## 已完成
- **4.1 / BUG-626**`holdoutFollowupFor``declinedForHoldout``duplicate_focus``rectification_focus_duplicate` 并走 `persistExhaustionCollect`;穷尽旁白为空时用 `noCandidatesGate``persistExhaustionCollect` 只在 `duplicate_focus` 时落到门槛句,其它 persist 失败仍返回采集旁白,避免证据轮被误标 `terminalNote` 而不裁句。
- **4.2 / BUG-627**`inspectNonTerminalTurnExit.satisfied` 去掉 `exhausted`,改认活动问题 / 采用 / 确认 / terminalNote host turn / `publicCanAdopt`。无载体时写出门槛 host turn。idle catch 仍调 `ensureNonTerminalTurnExit`
- **4.3**`POST /api/rectification/cases/[caseId]/repair-exit`;按钮「接着问」;两次仍无载体显示「暂时接不上,请新建一次校正。」
- **4.4**BUG-626 / 627、CHANGELOG、本文件、走查第 12 条。
## 验收命令
`frontend/`
- `./node_modules/.bin/tsc --noEmit`:通过
- `npm run lint`0 error(既有 107 warning
- 定向 `tsx --test`
- `rectification-skipped-health-deadend-20260909.test.ts`
- `rectification-collect-direction-20260904.test.ts`
- `rectification-exhaustion-exit-20260906.test.ts`
- `rectification-surface-contract.test.ts`
- `rectification-agentic-entry.test.ts`
- `rectification-spoken-collect.test.ts`
- `rectification-collect-stall.test.ts`
**195 / 195 通过**(含 `rectification-v9-stream.test.ts``rectification-answer-choice.test.ts`
- 全量 `tests/rectification-*.test.ts`(排除 database):**1067 / 1067 通过**
- 相关补充:`rectification-server-focus.test.ts``rectification-decision-authority.test.ts``rectification-covered-domain-recollect-20260908.test.ts`**39 / 39 通过**
本单状态:**待验收**。未改 Skill 版本、未改采用/确认门、未新增迁移。
+1 -1
View File
@@ -90,7 +90,7 @@
| `TASK-rectification-compare-card-polish-20260909.md` | `PROGRESS-rectification-compare-card-polish-20260909.md` | 三列卡两列经历对照 0、三列无未来窗:引擎按引擎前三算 by_time,卡片按推断前三查表,键不匹配(BUG-614);交付旁白被 BUG-606 裁成一句(BUG-615);设计减法:相同性格句只写一次、经历对照说人话、删小标题、右栏本命宫位改北印星盘、换升时刻默认折叠 | 已验收通过(by_time 只算 9 个 → BUG-632 | BUG-614615 |
| `TASK-rectification-history-open-20260909.md` | `PROGRESS-rectification-history-open-20260909.md` | 历史生时校正打不开:`open_agentic_rectification_case_v2` 把 Case 绑定 Skill 身份与当前注册版本做相等比较,两天内 10.0.15→10.0.19 五次 bump 让所有旧 Case 被拒;错误码未映射成 500;错误只画在首页卡下 → 表现为点了没反应。决策:session 打开用绑定身份(v1 取 Case → 读身份 → 注册表核验 → v2),不改迁移 | 已验收通过 | `a31a5e24`BUG-621 |
| `TASK-rectification-window-cluster-cap-20260909.md` | `PROGRESS-rectification-window-cluster-cap-20260909.md` | **P0** 真实用户:一小时窗口分 17 个签名簇,`select_signature_representatives` 按时间取前 12 个,14:40 之后整簇丢弃(本机复现表);区间按代表分钟跨度而非簇覆盖;中途说出真实时段时助手口头答应却未改 → 改为固定回复"范围开始时按资料定、中途不改"(产品否决口头改范围);intake 自定义范围等产品答复 | 已验收通过(申报拦截误伤经历 → BUG-631;intake 自定义范围等产品) | `6008c07c`BUG-623625Skill 10.0.20 |
| `TASK-rectification-skipped-health-deadend-20260909.md` | `PROGRESS-rectification-skipped-health-deadend-20260909.md` | 真实用户:健康题「记不清」后职业答完即断(『没有拿到下一个问题』):holdout 的 `declined` 未归并 health/health_pressure,把跳过的健康线再问一次,撞同 id 焦点 `duplicate_focus` 静默;出口闸门把 `exhausted` 当已交付不修复;『重新加载』只重取快照 | 待执行 | `codex/rectification-skipped-health-deadend-20260909`BUG-626627 |
| `TASK-rectification-skipped-health-deadend-20260909.md` | `PROGRESS-rectification-skipped-health-deadend-20260909.md` | 真实用户:健康题「记不清」后职业答完即断(『没有拿到下一个问题』):holdout 的 `declined` 未归并 health/health_pressure,把跳过的健康线再问一次,撞同 id 焦点 `duplicate_focus` 静默;出口闸门把 `exhausted` 当已交付不修复;『重新加载』只重取快照 | 待验收 | `codex/rectification-skipped-health-deadend-20260909`BUG-626627 |
| `TASK-rectification-domain-alias-audit-20260909.md` | `PROGRESS-rectification-domain-alias-audit-20260909.md` | 领域命名审计:健康线 `health`(账本/焦点)与 `health_pressure`(计划/引擎)在十处比较里六处未归并(holdout declined、reverse-verify、conflict probes、probeYearAlreadyCovered、引擎 oos_blind_prompts / _event_years / volunteered);职业线焦点存为 `other` 只靠 questionId 兜底。其余六领域三层同名无问题。决策:TS/Python 各一个归并函数 + 源码合同禁字面量比较 | 待执行 | `codex/rectification-domain-alias-audit-20260909`BUG-628 |
| `TASK-rectification-yearless-probe-downgrade-20260909.md` | `PROGRESS-rectification-yearless-probe-downgrade-20260909.md` | 产品拍板:无年月性格题(D9/D10 风格、月宿边界)降级为平局裁决——带年月题问完且候选仍分不开才问,分值减半(±1)、不计淘汰,报告标「参考」;三列卡性格描述不动;新增离线命中率测量脚本 + 导出 SQL(有出生证用户,聚合);Skill 10.0.21 | 已验收通过 | `fef54b6e`BUG-629Skill 10.0.21 |
| `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待验收 | `codex/rectification-followups-20260909`BUG-631632 |
@@ -175,7 +175,7 @@
- 每轮助手正文一句复述(可另接一句范围变化);气泡里没有「本轮对照了…」,也没有「很有帮助 / 很有价值 / 很有分量 / 特别有用」
- 报经历时带钟点(「20:00 左右分手」「下午 3 点到 5 点被车撞」)不得被当成申报出生时段,不得回「搜索范围开始时按资料定、过程中不改」
## 8. 性格题只作平局参考(BUG-629)
## 12. 性格题只作平局参考(BUG-629)
资料与开场同第 0 条。虚构经历。
@@ -187,7 +187,7 @@
- 交付后展开验证报告,「D9 / D10 类型对照」和「月宿边界」状态是 `reference`,说明是性格自评、只作排序参考、不参与淘汰
- 三列对照卡上的性格句子仍按类型表展示,不改写
## 8. 证据轮模型没写正文时必须出现「记下了」和下一问
## 13. 证据轮模型没写正文时必须出现「记下了」和下一问
资料与开场同第 0 条。虚构经历说到搬家或入职一件带年月的事。
@@ -197,3 +197,17 @@
- 下一问可见(气泡里或主持人问题行),输入框在问题可见时才写「请回答上面的问题…」
- 点选题范围没变时应能看到领先或落后;全零才只说「范围没变」
- 顶部时间线不得写「还在收窄」
## 14. 健康题点「记不清」后职业答完必须出卡或门槛句
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。虚构经历。
采集走到健康题时打字「记不清」,再答完职业题。
期望:
- 职业答完后必须出现下一张卡、下一问或门槛句
- 不得出现「没有拿到下一个问题。」
- 缺口按钮若出现,文案是「接着问」,点了会向服务端修复,不是只重取快照
- 连续两次仍无载体才显示「暂时接不上,请新建一次校正。」
+3 -3
View File
@@ -229,7 +229,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card |
| `question-gap`, persisted question | snapshot already has the next prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” |
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
| `candidates` | one range-delivery card of up to three compare columns (highest posterior first; “更像这个” adopts) under the offering message; a closed “查看验证报告” fold | enabled |
@@ -238,9 +238,9 @@ The birth-time rectification session is the consultation transcript plus a house
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
| stopped | the row settles with what streamed; a grey caption “已停止,已生成的内容保留;本次不会扣点。” under the body, never `role="alert"` | enabled |
- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a reload button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves.
- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a repair button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves.
- **Board:** before any candidate exists the header clock shows the declared birth minute and the body reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one.
- **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the reload and start actions are real 44px buttons.
- **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the repair and start actions are real 44px buttons.
### Birth time intake
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
loadV9CaseDossier,
loadV9CaseSkillIdentityStatus,
loadV9TurnReceipt,
RectificationToolServiceError,
listV10ConversationFocuses,
} from "@/lib/rectification-agentic/v9/tool-service";
import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice";
import { dossierResponse } from "../route";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ caseId: string }> };
const repairSchema = z.object({
sessionId: z.string().uuid().optional(),
}).strict();
/**
* POST /api/rectification/cases/[caseId]/repair-exit
*
* Repair a turn that settled without a visible next question or card.
* Only runs ensureNonTerminalTurnExit, then returns the same snapshot as GET.
*/
export async function POST(request: Request, context: RouteContext) {
let supabase;
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit");
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const { caseId } = await context.params;
if (!z.string().uuid().safeParse(caseId).success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
}
const parsed = repairSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_repair_request" }, { status: 400 });
}
try {
await ensureNonTerminalTurnExit({
accounting,
userId: user.id,
caseId,
});
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
if (parsed.data.sessionId && dossier.case.sessionId !== parsed.data.sessionId) {
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
}
const skillIdentity = await loadV9CaseSkillIdentityStatus(accounting, user.id, caseId);
const receipts = await Promise.all(
dossier.turns.map(async (turn) => {
try {
return await loadV9TurnReceipt(accounting, user.id, caseId, turn.id);
} catch {
return null;
}
}),
);
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
return NextResponse.json({
ok: true,
...dossierResponse(dossier, receipts, skillIdentity, { listed }),
});
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const message = error.message;
if (message.includes("agentic_rectification_case_not_found")) {
return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 });
}
}
return NextResponse.json(
{ error: "暂时无法接上下一个问题", code: "rectification_repair_failed" },
{ status: 503 },
);
}
}
@@ -93,7 +93,7 @@ export async function GET(request: Request, context: RouteContext) {
}
}
function dossierResponse(
export function dossierResponse(
dossier: V9CaseDossier,
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
@@ -52,6 +52,8 @@ import {
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
RECTIFICATION_QUESTION_PREPARING_LABEL,
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_REPAIR_FAILED_COPY,
RECTIFICATION_QUESTION_REPAIR_LIMIT,
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
@@ -484,6 +486,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
const [questionRepairing, setQuestionRepairing] = useState(false);
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
@@ -547,6 +551,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setBusy(value);
// A turn starting is a fresh chance for the next question to arrive.
if (value) setQuestionRetryAttempts(0);
if (value) setQuestionRepairAttempts(0);
onPendingChange?.(value);
}, [onPendingChange]);
@@ -649,6 +654,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setCaseSnapshotLoaded(true);
if (nextQuestion || nextChoice || acceptedTime || confirmedTime) {
setQuestionRepairAttempts(0);
}
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
@@ -1500,7 +1508,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
));
// The gap between a settled turn and its next question has two visible
// states: `preparing` (one live timeline row, timed refetches) and
// `unavailable` (copy and a reload button) — never bare copy telling the
// `unavailable` (copy and a repair button) — never bare copy telling the
// reader to wait for the server.
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
@@ -1532,7 +1540,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
});
// Question recovery: the turn already refetched once on completion; while
// the gap is `preparing`, refetch on a timer up to the retry limit, then
// hand the reader a reload button. Attempts reset once a question arrives
// hand the reader a repair button. Attempts reset once a question arrives
// or another turn starts (both in handlers, never in an effect).
useVisibilityAwarePoll({
enabled: questionGap === "preparing",
@@ -1546,9 +1554,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
async function refetchQuestion() {
await loadCaseSnapshot();
}
function reloadQuestion() {
setQuestionRetryAttempts(0);
void refetchQuestion();
async function repairQuestion() {
if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return;
setQuestionRepairing(true);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/repair-exit`,
{
method: "POST",
headers: { "content-type": "application/json" },
cache: "no-store",
body: JSON.stringify({ sessionId }),
},
);
const payload = await response.json().catch(() => null);
if (response.ok && payload) {
startTransition(() => {
applyCaseSnapshot(payload);
});
const recovered = Boolean(
currentQuestionFromSnapshot(payload.current_question)
|| parseRectificationChoiceCard(payload.choice_card)
|| payload.case?.accepted_time
|| payload.case?.confirmed_time
);
if (!recovered) setQuestionRepairAttempts((current) => current + 1);
} else {
await loadCaseSnapshot();
setQuestionRepairAttempts((current) => current + 1);
}
} catch {
setQuestionRepairAttempts((current) => current + 1);
} finally {
setQuestionRepairing(false);
}
}
// A Case with no turns and no automatic first turn (the server never starts
@@ -1768,10 +1807,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
{questionGap === "unavailable" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="outline" onClick={reloadQuestion}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}</p>
) : (
<>
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="outline" onClick={() => void repairQuestion()} disabled={questionRepairing}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
</>
)}
</div>
)}
{questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
@@ -32,7 +32,7 @@ import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { ensureNonTerminalTurnExit, persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { alreadyDelivered } from "./delivery-turn-guard";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import {
@@ -622,6 +622,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
console.warn(
`[rectification-v9] persist interview before collect attach failed case=${caseId} reason=${safeErrorCode(error)}`,
);
try {
interviewIdle = await ensureNonTerminalTurnExit({ accounting, userId, caseId });
} catch (repairError) {
console.warn(
`[rectification-v9] nonterminal exit after idle failure failed case=${caseId} reason=${safeErrorCode(repairError)}`,
);
}
}
}
if (action === "evidence") {
@@ -30,7 +30,7 @@ import {
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { datedEventCount } from "./divergence-panel.ts";
import { logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
import {
decideAfterInferenceChange,
decideFromDossier,
@@ -114,6 +114,47 @@ export function isExhaustedGateState(input: {
&& !input.accepted;
}
export function looksLikeTerminalNoteNarration(text: string | null | undefined): boolean {
const trimmed = text?.trim() ?? "";
if (!trimmed) return false;
return trimmed.includes(RECTIFICATION_USER_COPY.noCandidatesGate)
|| trimmed.includes("这只是代表性候选")
|| /还差(?: \d+ 件)?带月份的经历/.test(trimmed)
|| trimmed.includes("两件事的日期还没对清")
|| trimmed.includes("这次给出的范围");
}
function terminalNoteHostPresent(input: {
caseId: string;
dossier: {
turns?: readonly Readonly<{ role: string; text: string | null }>[];
latestResult?: { resultId?: string } | null;
};
}): boolean {
const resultId = input.dossier.latestResult?.resultId ?? "";
if (alreadyDelivered({
caseId: input.caseId,
resultId,
hasUserMessage: false,
})) return true;
return (input.dossier.turns ?? []).some((turn) => (
turn.role === "assistant" && looksLikeTerminalNoteNarration(turn.text)
));
}
function logDuplicateFocus(caseId: string, questionId: string | null | undefined): void {
console.warn(JSON.stringify({
event: "rectification_focus_duplicate",
case_id: caseId,
question_id: questionId ?? null,
}));
}
function exhaustionCarrierNarration(range: string | null | undefined, gate: string | null | undefined): string {
return [range, gate].filter((item): item is string => Boolean(item?.trim())).join("")
|| RECTIFICATION_USER_COPY.noCandidatesGate;
}
export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string {
return `${askedTurnId ?? caseId}:gate`;
}
@@ -132,6 +173,9 @@ export async function persistExhaustionGateTurn(input: {
resultId: input.resultId ?? null,
terminalNote: true,
});
if (input.resultId) {
markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
}
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
userMessage: null,
@@ -868,6 +912,9 @@ export async function persistNextInterviewAfterChoice(input: {
}
if (followup?.intent === "collect_method_evidence") {
const spoken = spokenFollowupForUser(followup);
if (persistedFocus.status === "duplicate_focus") {
logDuplicateFocus(input.caseId, persistedFocus.questionId);
}
if (
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
@@ -945,6 +992,9 @@ async function persistFocusAfterChoice(input: {
|| persisted.status === "duplicate_focus"
|| !input.followup
) {
if (persisted.status === "duplicate_focus") {
logDuplicateFocus(input.caseId, persisted.questionId);
}
return persisted;
}
if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) {
@@ -1422,12 +1472,28 @@ async function persistExhaustionCollect(input: {
const persisted = Boolean(spoken) && (
persistedFocus.status === "created" || persistedFocus.status === "already_open"
);
return {
persisted,
choiceReady: false,
hostNarration: spoken ?? range,
focus: persistedFocus.focus,
};
if (persisted) {
return {
persisted,
choiceReady: false,
hostNarration: spoken ?? range,
focus: persistedFocus.focus,
};
}
// duplicate_focus: this ask is already closed; fall through to adopt/gate.
// Other persist misses still return the spoken collect so mid-session
// turns keep a next question instead of a terminal gate.
if (persistedFocus.status !== "duplicate_focus") {
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
if (hostNarration) {
return {
persisted: false,
choiceReady: false,
hostNarration,
focus: persistedFocus.focus,
};
}
}
}
if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) {
const adopted = {
@@ -1463,7 +1529,7 @@ async function persistExhaustionCollect(input: {
openingRange: openingRangeFromDossier(dossier),
variant: "intermediate",
});
const hostNarration = [range, gate].filter(Boolean).join("");
const hostNarration = exhaustionCarrierNarration(range, gate);
return {
persisted: false,
choiceReady: false,
@@ -1713,42 +1779,13 @@ async function inspectNonTerminalTurnExit(input: {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: null,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
...catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
...followupCaseArgs({
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}),
});
const exhausted = isExhaustedGateState({
remainingCollect,
methods: plan.methods,
canAdopt: decision.canAdopt,
accepted: Boolean(dossier.case.acceptedTime),
});
const satisfied = Boolean(
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|| dossier.case.acceptedTime
|| dossier.case.confirmedTime
|| decision.completionStatus === "provisional_range_user_stopped"
|| publicCanAdopt(decision)
|| exhausted
|| terminalNoteHostPresent({ caseId: input.caseId, dossier })
);
return { dossier, decision, satisfied };
}
@@ -1775,11 +1812,38 @@ export async function ensureNonTerminalTurnExit(input: {
decision: before.decision,
decisionReceipt: before.dossier.latestResult?.decisionReceipt,
});
if (repaired.persisted && repaired.focus) {
const afterFocus = await inspectNonTerminalTurnExit(input);
if (!afterFocus.satisfied && !repaired.hostNarration) {
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
}
return repaired;
}
const hostNarration = (repaired.hostNarration ?? "").trim()
|| RECTIFICATION_USER_COPY.noCandidatesGate;
try {
await persistExhaustionGateTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
hostNarration,
resultId: before.dossier.latestResult?.resultId ?? null,
});
} catch (error) {
console.warn(
`[rectification-v9] persist exhaustion gate during exit repair failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
}
const after = await inspectNonTerminalTurnExit(input);
if (!after.satisfied && !repaired.terminalNote) {
if (!after.satisfied && !hostNarration) {
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
}
return repaired;
return {
...repaired,
persisted: true,
hostNarration,
terminalNote: true,
};
}
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
@@ -1778,6 +1778,15 @@ function holdoutAskFields(
};
}
function declinedForHoldout(declined: ReadonlySet<string>): Set<string> {
const next = new Set(declined);
if (declinedHealth(declined)) {
next.add("health");
next.add("health_pressure");
}
return next;
}
export function holdoutFollowupFor(
input: {
evidence: readonly MethodFollowupEvidence[];
@@ -1788,21 +1797,13 @@ export function holdoutFollowupFor(
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
const occupied = holdoutOccupiedDomains(input.evidence);
const healthDeclined = declinedHealth(declined);
const prompt = (input.oosBlindPrompts ?? []).find((item) => {
if (!item.domain || occupied.has(item.domain)) return false;
if (item.domain === "health" || item.domain === "health_pressure") {
return !healthDeclined;
}
return !declined.has(item.domain);
}) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => {
if (item.year == null || occupied.has(item.domain)) return false;
if (item.domain === "health" || item.domain === "health_pressure") {
return !healthDeclined;
}
return !declined.has(item.domain);
}) ?? null;
const blocked = declinedForHoldout(declined);
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain)
)) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => (
item.year != null && !occupied.has(item.domain) && !blocked.has(item.domain)
)) ?? null;
return holdoutAskFields(prompt, reserved);
}
@@ -29,7 +29,9 @@ export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
@@ -291,7 +293,7 @@ export type RectificationQuestionGapInput = Readonly<{
* The gap between a settled turn and its next question. While retries remain
* it is `preparing` (one live row, timed refetches); once they run out, or the
* server itself reports the question unavailable, it is `unavailable` with a
* manual reload. A snapshot that never arrived (hydration timed out) is a gap
* repair button. A snapshot that never arrived (hydration timed out) is a gap
* too: the same retries fill it. Questions themselves live inside assistant
* messages, so a visible live question means there is no gap.
*/
@@ -896,3 +896,24 @@ test("holdout waits until dated collect and occupation are asked", () => {
assert.equal(plan.next_followup?.domain, "relocation");
assert.notEqual(plan.next_followup?.domain, "health_pressure");
});
test("skipped ledger health blocks health_pressure holdout", () => {
const evidence: MethodFollowupEvidence[] = [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2017-04-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
];
const oos = [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false as const,
}];
assert.equal(holdoutFollowupFor({
evidence,
oosBlindPrompts: oos,
}, new Set(["family", "health"])), null);
});
@@ -16,6 +16,7 @@ import {
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts";
@@ -463,10 +464,17 @@ function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType
});
}
function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType<typeof activeFocusFixture>; allowFocus?: boolean } = {}) {
function idleHandlers(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture>;
allowFocus?: boolean;
turns?: unknown[];
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra),
get_agentic_rectification_case_dossier: () => {
const row = rpcDossier(decision, extra);
return extra.turns ? { ...row, turns: extra.turns } : row;
},
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
@@ -869,3 +877,48 @@ test("closed ceiling with holdout still open persists holdout not the gate", asy
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
assert.match(persisted.hostNarration ?? "", /入职|换工作|工作/);
});
test("ensureNonTerminalTurnExit writes a host turn when exhausted with no carrier", async () => {
resetDeliveryTurnGuardForTests();
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
holdoutUnavailable: true,
});
const accounting = idleHandlers(dossier);
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.ok(repaired.hostNarration);
assert.equal(repaired.terminalNote, true);
assert.equal(gateAppendCalls(accounting.calls).length, 1);
});
test("ensureNonTerminalTurnExit does not rewrite when a terminal-note host turn exists", async () => {
resetDeliveryTurnGuardForTests();
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
holdoutUnavailable: true,
});
const accounting = idleHandlers(dossier, {
turns: [
{
id: "99999999-9999-4999-8999-999999999999",
role: "assistant",
text: RECTIFICATION_USER_COPY.noCandidatesGate,
status: "completed",
created_at: "2026-09-09T00:00:00.000Z",
},
],
});
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(repaired.hostNarration, null);
assert.equal(gateAppendCalls(accounting.calls).length, 0);
});
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import {
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_REPAIR_FAILED_COPY,
} from "../src/lib/rectification-surface-state.ts";
import {
holdoutFollowupFor,
type MethodFollowupEvidence,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
const SEVEN_WITHOUT_HEALTH: MethodFollowupEvidence[] = [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-04-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
];
function rpcEvidence(item: MethodFollowupEvidence & { id: string }) {
return {
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-09T00:00:00.000Z",
};
}
function skippedHealthDossier() {
const evidence = SEVEN_WITHOUT_HEALTH.map((item) => rpcEvidence({ ...item, id: item.id ?? `e-${item.domain}` }));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "14:45",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(SEVEN_WITHOUT_HEALTH as never),
decisionReceipt: {
oos_blind_prompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: [
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{
target_domain: "health",
status: "skipped",
intent: "collect_method_evidence",
question_id: "collect:health_pressure:collect_method_evidence",
},
],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-09T00:00:00.000Z",
},
});
}
function warnLines(run: () => Promise<unknown>) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
};
return Promise.resolve()
.then(run)
.then((result) => ({ result, lines }))
.finally(() => {
console.warn = original;
});
}
test("skipped health ledger domain keeps holdoutFollowupFor null", () => {
assert.equal(holdoutFollowupFor({
evidence: SEVEN_WITHOUT_HEALTH,
oosBlindPrompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
}, new Set(["health"])), null);
});
test("persistNextInterviewIfIdle still narrates after duplicate_focus", async () => {
const raw = skippedHealthDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: () => {
throw new Error("focus_idempotency_conflict");
},
});
const { result, lines } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const idle = result as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.ok((idle.hostNarration ?? "").trim());
assert.notEqual(idle.hostNarration, "");
assert.ok(
idle.terminalNote === true || Boolean(idle.hostNarration),
"duplicate_focus must still leave a visible carrier",
);
assert.ok(lines.some((line) => line.includes("rectification_focus_duplicate") || line.includes("rectification_exhaustion_collect")));
});
test("unavailable gap button posts repair-exit and says 接着问", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const surface = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
const repair = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts", import.meta.url),
"utf8",
);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.equal(RECTIFICATION_QUESTION_RELOAD_LABEL, "接着问");
assert.match(surface, /RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问"/);
assert.match(surface, /暂时接不上,请新建一次校正/);
assert.equal(RECTIFICATION_QUESTION_REPAIR_FAILED_COPY, "暂时接不上,请新建一次校正。");
assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/);
assert.match(chat, /RECTIFICATION_QUESTION_REPAIR_FAILED_COPY/);
assert.doesNotMatch(chat, /function reloadQuestion/);
assert.match(repair, /ensureNonTerminalTurnExit/);
assert.match(repair, /POST \/api\/rectification\/cases\/\[caseId\]\/repair-exit/);
const catchBlock = agentRun.slice(agentRun.indexOf("persist interview before collect attach failed"));
assert.match(catchBlock.slice(0, 500), /ensureNonTerminalTurnExit/);
});