fix(rectification): unblock range-card tsc and restore post-adopt windows (BUG-583)
Independent Staging Quality Gate / validate (push) Successful in 13m23s
Independent Staging Quality Gate / publish (push) Successful in 30m54s

Null-check the candidate result before reading resultId so next build can ship. Adopt reconnects prospective windows on the first verify turn, and dismissing the range card now shows the collect hint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-07 20:22:24 +08:00
parent f3e7270237
commit a00b069dcc
13 changed files with 194 additions and 16 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-07 — 区间卡能编译,采用后才说预测窗口,再补经历有下一句提示
生时校正交付卡不再把编译挡在门外。点采用后,第一轮核对旁白末尾会接上实际的下次事业变动窗口(有才写)。点「再补一件经历」后卡片收起,输入框上方改成收集经历的提示。Skill 版本仍是 10.0.15。
## 2026-09-07 — 生时校正结尾改成一张区间卡,不再让你在分钟里挑
问完经历后,交付轮只出一张卡:这段范围、排盘用的代表分钟、这段里稳定和会随分钟变的主题。如果几段之间还有没问过的关系盘或事业盘差别,会并排两三列对照,文案只来自类型表。动作只有「按这个范围用」和「再补一件经历」。不再显示相对支持度,也不再并排三行「候选 A 预测下次事业变动」。Skill 版本 10.0.15。
+16
View File
@@ -9030,5 +9030,21 @@
- 复发自:BUG-558
- 修复版本:待发布(`codex/rectification-stop-rescore-fix-20260907`
## BUG-583 | 区间交付卡判空顺序使 tsc 失败,staging 无法部署
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-07
- 影响面:`rectification-agentic-chat.tsx` 区间卡渲染、采用后首轮旁白、`RectificationRangeDelivery`「再补一件经历」
- 用户现象:staging 门禁与 `next build` 因 TypeScript 失败,BUG-581~582 的修复无法部署。采用后看不到承诺过的预测窗口。点「再补一件经历」后卡片消失、输入框上方没有下一句。
- 触发条件:`ab57d03f` 合入后跑 `tsc --noEmit`;或交付轮点「再补一件经历」;或采用后进入核对。
- 根因:渲染条件先读 `candidateResult.resultId` 再写 `candidateResult &&`,收窄发生在属性读取之后。`prospectiveWindowsNarration` 从交付旁白撤掉后没有接到采用后首轮。`onContinue` 只改本地 dismiss,不给采集提示。步骤条类名 `rectification-step-state` 已由 BUG-575 禁止,不能靠加回步骤条过关。
- 修复:判空提到 `dismissedRangeResultId` 之前。`persistNextInterviewIfIdle` 在已采用时把 `prospective_probes` 接到首轮旁白末尾;`deferredCareerWindow` 仍留在交付旁白。收起卡片后用 `RangeDeliveryContinueHint` 显示「第 1 步·收集经历」加 `continueCollectFallback`,不用被禁的步骤条类名。
- 验证:`frontend/tests/rectification-range-delivery-20260907.test.ts``tsc --noEmit` 原文见 `PROGRESS-rectification-range-delivery-fix-20260907.md`
- 防复发:区间卡渲染源码必须先出现 `candidateResult &&` 再读 `resultId`。采用后首轮 idle persist 必须调用 `appendPostAdoptProspectiveWindows`。聊天源码仍不得出现 `rectification-step-state`
- 相关记录:BUG-575、BUG-581、BUG-582
- 复发自:无(区间卡 `ab57d03f` 引入;任务书 4.2 验收未过)
- 修复版本:待发布(`codex/rectification-range-delivery-fix-20260907`
@@ -0,0 +1,50 @@
# PROGRESS · 区间交付卡修复单(2026-09-07
工作树:`.worktrees/rectification-range-delivery-fix-20260907`
分支:`codex/rectification-range-delivery-fix-20260907`
任务书:`docs/tasks/TASK-rectification-range-delivery-fix-20260907.md`
基线:`origin/staging` @ `f3e72702`(任务书写 `ab57d03f`;其上有验收文档提交)
本单状态:**待验收**。未 commit、未 push。
未改:采用 RPC 校验、确认门、`MIN_SEPARATION_LEAD``_relative_support``page.tsx`、Skill 版本(仍 10.0.15)、聊天源码不出现 `rectification-step-state`BUG-575)。
## 做了什么
- **3.1** `rectification-agentic-chat.tsx` 渲染条件改为 `showSelectionCards && candidateResult && dismissedRangeResultId !== candidateResult.resultId && …`
- **3.2** 选「接上」:`persistNextInterviewIfIdle``acceptedTime` 时把 `prospectiveWindowsNarration(receipt.prospective_probes)` 接到首轮旁白末尾。交付旁白仍留 `deferredCareerWindow`。跳过最后一道核对题的 `postAdoptVerifyDone` 不走 idle 包装,仍是原句。
- **3.3** 「再补一件经历」收起卡片后,输入框上方 `RangeDeliveryContinueHint` 显示 `STEP_STATE_COPY.collect.headline` + `continueCollectFallback`。不用被禁的 `.rectification-step-state`
- **3.4** BUG-583、CHANGELOG、本文件、场景第 5 条、DESIGN / VOICE。
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| 区间卡渲染条件 | 先读 `candidateResult.resultId``candidateResult &&` | 先 `candidateResult &&` | TS18047 |
| 采用后首轮旁白 | 无 `prospectiveWindowsNarration` 调用点 | idle persist 已采用时追加 | 决策 2 接上 |
| 「再补一件经历」 | 只 `setDismissedRangeResultId` | 另显示收集经历提示 | 决策 3;不恢复步骤条类名 |
## 测试
开工前 `frontend` `./node_modules/.bin/tsc --noEmit; echo "tsc exit $?"` 原文:
```
src/components/rectification-agentic-chat.tsx(1655,67): error TS18047: 'candidateResult' is possibly 'null'.
tsc exit 2
```
修复后同一命令原文(stdout 为空,只有 echo):
```
tsc exit 0
```
- 任务书指定 TS 套件(`rectification-*.test.ts` + `consultation-*.test.ts` + `agent-voice-copy-contract.test.ts` + `skill-registry.test.ts`,排除 database):**1230 passed / 0 failed**`ab57d03f` 同切片 1227;本单 +3
- 本单改动文件 eslint**0 error**chat 上 2 条既有 hooks warning
- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked
- 浏览器点选「再补一件经历」:无登录态 / Chrome 驱动,记环境缺口,见 `docs/testing/rectification-scenarios-20260907.md` §5
## 偏离
1. 步骤条文案用 `RangeDeliveryContinueHint``.rectification-continue-hint`),没有恢复 `.rectification-step-state`。BUG-575 防复发与 `rectification-step-state-20260906.test.ts` 禁止该类名。
2. 预测窗口挂在 `persistNextInterviewIfIdle`(采用 RPC 之后的首轮),不挂 `applyRectificationChoice` 的跳过核对收尾,以免改掉 `postAdoptVerifyDone` 全等断言。
+1 -1
View File
@@ -71,7 +71,7 @@
| `TASK-rectification-stale-compare-fix-20260907.md` | `PROGRESS-rectification-stale-compare-fix-20260907.md` | **P0**BUG-559 把过长 `candidate_split_hash` 塞进引擎比较键,答完性格/边界卡后比较静默失败;快照过期压过用户停止;holdout 用过期领域被改写成「再说一件事」;采集轮正文双写 | 已验收通过(P2 正文双写根因未拆、P3 重算节流 Map 只增不减) | `4e0db55f`(落地编号 BUG-577580);staging 未部署 |
| `TASK-rectification-stop-rescore-fix-20260907.md` | `PROGRESS-rectification-stop-rescore-fix-20260907.md` | **P0**:BUG-579 的停止路径用「裸重算」(`rescoreMinuteAfterWindowChange` 不重建 `inference_state`、不重放已答探针、不写 transition),停止后范围变宽、卡片变成引擎裸支持度、采用 RPC 报 `candidate_state_inconsistent`;无框区分题经 `spokenCollectFallbackFollowup` 变成 domain=other 的采集题,再次冒出开场句 `GENERIC_COLLECT_QUESTION`;holdout 采集排在带年月采集之前 | 已验收通过(逻辑);但同提交含区间卡 tsc 错,整体不可部署 | `ab57d03f`BUG-581582 |
| `TASK-rectification-range-delivery-card-20260907.md` | `PROGRESS-rectification-range-delivery-card-20260907.md` | 结尾重设计:四张分钟卡 + 相对支持度换成一张区间交付卡(范围、代表分钟、稳定/敏感主题、边界句)+ 只列未问 D9/D10/月宿分歧的 2~3 列对比面板(文案只取类型表),动作只有「按这个范围用 / 再补一件经历」;删交付旁白里三行预测窗口;Skill §9 口径改、bump 10.0.15 | **未通过**:tsc 1 错(判空顺序);P2 预测窗口句兑现不了;P3 再补经历无提示 | `ab57d03f`;修复单 `TASK-rectification-range-delivery-fix-20260907.md` |
| `TASK-rectification-range-delivery-fix-20260907.md` | `PROGRESS-rectification-range-delivery-fix-20260907.md` | 区间卡修复单:`candidateResult` 判空提到最前(tsc 阻断);预测窗口接到采用后首轮或删句;「再补一件经历」隐藏卡后给采集提示;进度记录须贴 tsc 原文 | 待领取(当天须合入,否则 BUG-581 无法部署) | `codex/rectification-range-delivery-fix-20260907`BUG-583 |
| `TASK-rectification-range-delivery-fix-20260907.md` | `PROGRESS-rectification-range-delivery-fix-20260907.md` | 区间卡修复单:`candidateResult` 判空提到最前(tsc 阻断);预测窗口接到采用后首轮或删句;「再补一件经历」隐藏卡后给采集提示;进度记录须贴 tsc 原文 | 待验收 | `codex/rectification-range-delivery-fix-20260907`BUG-583 |
| `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505509 |
@@ -88,5 +88,5 @@
- 若有分歧面板,每列只有 1~3 行,且都是没问过的 D9 / D10 / 月宿层
- 不见「相对支持度」「当前推荐」「采用此时间」
- 点「按这个范围用」成功,设置页出生时间状态变为已采用
- 点「再补一件经历」卡片收起,服务端范围不变
- 点「再补一件经历」卡片收起,输入框上方出现「第 1 步·收集经历」和「请继续说下一件…」,服务端范围不变
+1 -1
View File
@@ -252,7 +252,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring.
- **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card (window, representative minute, stable/sensitive themes, boundary sentence) plus an optional unasked D9/D10/nakshatra panel. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels the representative minute as the true birth time.
- **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The composer has no `rectification-step-state` status sentence.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The composer has no `rectification-step-state` status sentence. After “再补一件经历” dismisses the range card, `rectification-composer-meta` shows the collect-step headline plus `continueCollectFallback`.
- **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey.
- **Scoring and retry:** `score_pending` is a quiet progress surface with cancellable bounded polling and no manual compare control. `retry_scoring` preserves the confirmed evidence and exposes one explicit retry action. Refresh and device changes resume from the persisted action rather than inferring progress from copy.
- **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute.
+2 -1
View File
@@ -33,7 +33,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 |
| 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。区间交付卡必须就在这句下面。 |
| 相对支持度 16 · 采用此时间 | 按这个范围用 / 再补一件经历 | 交付对象是区间,不是四张分钟卡上的支持度数字。 |
| 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 下一次事业变动的预测窗口留在采用后的核对阶段。 | 交付轮不并排三行预测。 |
| 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 下一次事业变动的预测窗口留在采用后的核对阶段。 | 交付轮不并排三行预测。采用后首轮核对旁白末尾才接上实际窗口句。 |
| 「再补一件经历」后卡片消失、输入框上方空白 | 第 1 步·收集经历。请继续说下一件你记得比较清楚、大概带年份的经历。 | 收起交付卡后要告诉人下一步做什么。 |
| 采用后会拿盘外核对来验证。 | 前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 已采用且没有下一道核对题时用 `postAdoptVerifyDone`,不再念 `adoptCue`。 |
| (采用后只念代表分钟)代表分钟是 05:12。 | 这 27 分钟里,事业方向、性格底色的判断是稳定的;婚恋(D9)会随分钟变,看盘时按范围读。这不是已确认的唯一出生分钟。 | 采用后先讲区间里什么稳、什么随分钟变;两句来自服务端,不是模型编的。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
+10
View File
@@ -3091,6 +3091,16 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-composer-meta .rectification-collect-stop {
margin: 0;
}
.rectification-continue-hint {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-continue-hint strong {
color: var(--color-ink);
font-weight: 600;
}
.rectification-candidates-heading { display: grid; gap: var(--space-1); }
.rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; }
.rectification-candidates-heading span,
@@ -39,7 +39,7 @@ import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { RangeDeliveryContinueHint, RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
@@ -1506,6 +1506,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void submitStructuredChoice(STOP_ACTION, "stop");
}
const rangeCardDismissed = Boolean(
candidateResult
&& dismissedRangeResultId === candidateResult.resultId
&& !candidateResult.selectedTime
&& savedStatus !== "accepted"
&& savedStatus !== "confirmed"
&& !readonly
);
const spokenCollectOpen = currentQuestion?.kind === "collect_spoken" && !readonly && !busy;
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -1652,7 +1661,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onRegenerate={() => void regenerateMessage(message)}
/>
)}
{showSelectionCards && dismissedRangeResultId !== candidateResult.resultId && message.renderKey === selectionCardMessageKey && candidateResult && (
{showSelectionCards && candidateResult && dismissedRangeResultId !== candidateResult.resultId && message.renderKey === selectionCardMessageKey && (
<RectificationRangeDelivery
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
@@ -1724,8 +1733,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<span className="rectification-adopt-status__hint"></span>
</div>
)}
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
{(rangeCardDismissed || spokenCollectOpen) && (
<div className="rectification-composer-meta">
<RangeDeliveryContinueHint visible={rangeCardDismissed && !busy} />
{spokenCollectOpen && (
<button
type="button"
className="rectification-collect-stop"
@@ -1733,6 +1744,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
>
{CHOICE_STOP_LABEL}
</button>
)}
</div>
)}
<ChatComposer
@@ -9,6 +9,7 @@ import {
rangeDeliverySensitiveCopy,
rangeDeliveryStableCopy,
} from "@/lib/rectification-agentic/user-copy";
import { STEP_STATE_COPY } from "@/lib/rectification-agentic/v9/step-state";
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
function adoptCandidateId(
@@ -25,6 +26,17 @@ function adoptCandidateId(
?? null;
}
export function RangeDeliveryContinueHint({ visible }: Readonly<{ visible: boolean }>) {
if (!visible) return null;
return (
<p className="rectification-continue-hint" role="status">
<strong>{STEP_STATE_COPY.collect.headline}</strong>
{" "}
{RECTIFICATION_USER_COPY.continueCollectFallback}
</p>
);
}
export function RectificationRangeDelivery({
result,
acceptingCandidateId,
@@ -92,7 +92,7 @@ import {
import { followupCaseArgs, isBlockChoiceSchema, isWidenWindowSchema } from "./block-scan.ts";
import { mutateCaseForBlockChoice, mutateCaseForWidenWindow, rescoreStaleMinuteSnapshotIfNeeded } from "./block-scan-answer.ts";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet";
import { projectCurrentQuestion } from "./turn-decision";
const EXHAUSTION_DELIVERY_ACTIONS = new Set([
@@ -143,6 +143,18 @@ function withDeferredCareerWindow(base: string): string {
return `${base} ${extra}`.replace(/\s+/g, " ").trim();
}
export function appendPostAdoptProspectiveWindows(
narration: string,
receipt: Readonly<Record<string, unknown>> | null | undefined,
): string {
const extra = prospectiveWindowsNarration(
refinementFromDecisionReceipt(receipt).prospective_probes,
);
if (!extra) return narration;
if (narration.includes(extra)) return narration;
return `${narration} ${extra}`.replace(/\s+/g, " ").trim();
}
async function withRangeReadingNarration(
base: string,
input: {
@@ -1105,21 +1117,26 @@ export async function persistNextInterviewIfIdle(input: {
caseId: input.caseId,
askedTurnId: input.askedTurnId ?? null,
});
let dossier = rescored.dossier;
const finishIdle = <T extends { hostNarration: string | null }>(result: T): T => {
let hostNarration = result.hostNarration;
if (dossier.case.acceptedTime && hostNarration) {
hostNarration = appendPostAdoptProspectiveWindows(
hostNarration,
dossier.latestResult?.decisionReceipt,
);
}
if (
input.userStopped === true
&& rescored.rescoreAttempted
&& !rescored.snapshotCurrent
&& result.hostNarration
&& hostNarration
) {
return {
...result,
hostNarration: withLastSuccessfulCompareNotice(result.hostNarration),
};
hostNarration = withLastSuccessfulCompareNotice(hostNarration);
}
return result;
if (hostNarration === result.hostNarration) return result;
return { ...result, hostNarration };
};
let dossier = rescored.dossier;
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
if (
@@ -169,6 +169,7 @@ test("delivery narration defers career windows and the range card replaces minut
assert.doesNotMatch(choice, /withProspectiveWindows/);
assert.doesNotMatch(choice, /候选 A 预测/);
assert.match(choice, /deferredCareerWindow/);
assert.match(choice, /appendPostAdoptProspectiveWindows/);
assert.match(chat, /<RectificationRangeDelivery/);
assert.doesNotMatch(chat, /相对支持度/);
assert.doesNotMatch(card, /相对支持度|概率|置信度/);
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
@@ -10,8 +12,11 @@ import {
d9DivergenceLabel,
} from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { D10_TYPE_TABLE, D9_TYPE_TABLE } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts";
import { REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_USER_COPY, REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
import type { NakshatraBoundary } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { appendPostAdoptProspectiveWindows } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { RangeDeliveryContinueHint } from "../src/components/rectification-range-delivery.tsx";
import { STEP_STATE_COPY } from "../src/lib/rectification-agentic/v9/step-state.ts";
const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2";
@@ -288,3 +293,53 @@ test("the range-delivery component does not render relative support", () => {
assert.match(source, /rangeDeliveryAdopt/);
assert.match(source, /rangeDeliveryContinue/);
});
test("chat null-checks candidateResult before reading dismissedRangeResultId", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const start = chat.indexOf("showSelectionCards && candidateResult");
assert.ok(start >= 0);
const slice = chat.slice(start, start + 280);
assert.match(slice, /candidateResult && dismissedRangeResultId !== candidateResult\.resultId/);
assert.ok(slice.indexOf("candidateResult &&") < slice.indexOf("dismissedRangeResultId"));
});
const CAREER_WINDOW_PROBE = {
candidate_label: "A",
domain: "career",
window_label: "2027 年 3 月附近",
user_meaning: "候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。这是预测窗口,不是承诺;下次发生时回来补一条,可进一步分辨。",
used_for_scoring: false,
};
test("post-adopt first-round narration appends prospective windows when present", () => {
const base = "2013 年前后,钱上有没有明显变化?";
const withWindows = appendPostAdoptProspectiveWindows(base, {
prospective_probes: [CAREER_WINDOW_PROBE],
});
assert.match(withWindows, /预测下一次事业变动/);
assert.match(withWindows, /2013 年前后/);
const without = appendPostAdoptProspectiveWindows(base, { prospective_probes: [] });
assert.equal(without, base);
assert.doesNotMatch(without, /预测下一次事业变动/);
const idle = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
const start = idle.indexOf("export async function persistNextInterviewIfIdle");
const slice = idle.slice(start, start + 1800);
assert.match(slice, /appendPostAdoptProspectiveWindows/);
assert.match(slice, /dossier\.case\.acceptedTime/);
});
test("dismissing the range card hides it and shows the collect-continue hint", () => {
const hidden = renderToStaticMarkup(createElement(RangeDeliveryContinueHint, { visible: false }));
assert.equal(hidden, "");
const shown = renderToStaticMarkup(createElement(RangeDeliveryContinueHint, { visible: true }));
assert.ok(shown.includes(STEP_STATE_COPY.collect.headline));
assert.ok(shown.includes(RECTIFICATION_USER_COPY.continueCollectFallback));
assert.doesNotMatch(shown, /rectification-step-state/);
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /RangeDeliveryContinueHint/);
assert.match(chat, /setDismissedRangeResultId\(candidateResult\.resultId\)/);
assert.match(chat, /rangeCardDismissed/);
});