Compare commits

..
Author SHA1 Message Date
Jesse_ChenandClaude Fable 5.1 e8e98bbdfc fix(web): 手机上时间轴读数不再被裁,「跳到最新」不再压住选项
375px 下读数第四项被 ellipsis 裁成「已…」、第五项整项不可见:compact
段仍加 44px 头像缩进(每侧 60px,可用内容只剩 255px),而 cfb41daf 又把
读数从四项变成五项,按 13px 逐字估宽需要 383px。compact 段改为只取
`space-4`、`column-gap` 收到 `space-2`、第五项 `已对照 N 件` 在 767px 以下
`display: none`(仍在 DOM 与轴 aria-label 里),四项约 292px / 可用 343px。
桌面段、条高 64/56px、滚动锚定逻辑均未动。

「跳到最新」浮层占 composer 上方 56px 带,而 `.message-list` 底部留白恰好
也是 56px,末条选项贴在浮层按钮正下方、正中间点不动。留白改为
`calc(clearance + space-3)`,静止时末条下方 84px = 56px 带 + 28px 空气;
`.conversation` 追加同高 `scroll-padding-block-end`。浮层位置、居中与可见
条件未动。

顺带记一条待产品裁决的口径冲突:DESIGN §10 禁列表写明「已对照 N 件经历」
不上条、仍归交付卡,`cfb41daf` 却把它加上且未改 DESIGN;本轮只在手机上把
它移出可见行,桌面保留原状。

BUG-918、BUG-919

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 00:32:23 +00:00
25 changed files with 606 additions and 767 deletions
+2 -8
View File
@@ -1,14 +1,8 @@
# 印度占星 Skill 更新日志
## 2026-09-17 — 不再问「你更接近哪一种相处方式」;灰掉的选项和「再说一件事」不会同屏
## 2026-09-17 — 手机上时间轴读数不再被裁,「跳到最新」不再压住选项
分盘风格题(「亲密关系里,你更接近哪一种相处方式?」这类)不再作为计分题出现。关系、事业、家人这些线只在引擎给得出带年份的事件时问「某年前后有没有…」,给不出就直接问下一条线或给结果,不再拿性格描述去淘汰候选时间。性格描述仍然出现在平局时的参考题和交付卡的性格列里。
上一版这类题会出现在正文里但答不了:四个选项灰着点不动,同屏的范围行和输入框却写着「再说一件带年月的事」。现在这种自相矛盾的画面不会再有——拿不到可点的题时只会出现「没有拿到下一个问题」和「接着问」,占位文案跟着一致。已经答不了的旧题也不会再画成一排灰选项。
如果你 2026-09-16 到 09-17 之间在校正里遇到过风格题,那一次的答案可能计错了分,不要采用当时的区间;补一件带年月的事触发重算,或新开一段校正。
Skill 版本不变。
窄屏(375 / 390 宽)打开生时校正时,上方时间轴那一行「范围 · 宽度 · 代表分钟 · 已答 N 题」四项完整显示,不再出现「已…」这种被裁一半的字。为腾出宽度,手机上去掉了这条与头像列对齐的 44px 缩进,并把「已对照 N 件」这项收回交付卡(桌面不变;读屏软件在两种宽度下读到的内容都一样)。「跳到最新」胶囊与对话末条之间留出空隙,不再压在选项卡最后一行上。Skill 版本不变。
## 2026-09-16 — 正文念出的判别题必有卡;性格描述不再自相矛盾
+27 -41
View File
@@ -5894,8 +5894,8 @@
- 根因:(1) `stampChoiceSchemaWithProbe` 用引擎最高增益探针盖 schema,把对比探针换成已答教育题;`event_probe` 缺 `probe_id` 时被当成已答,不建焦点。GET 投影不传 `contrastPacket` / `userStopped`。(2) 对比探针按整窗 D10 星座建题,`supportsCandidateIds` 是星座名不是分钟;`askedKeys` 不含自然语言答过的职责倾向。(3) 质量探针先占领域,挡住 dasha 年界;代表对取整窗第一次换升。`answersFromEvidence` 把同年入学当成考试失常。(4) 停问词匹配不到「没有了」。
- 修复:对比探针用自己的 `semantic_key` 盖戳,缺引擎 `probe_id` 时仍建焦点;GET 与工具侧同一套 plan 输入。剩余候选按 D24/D10 分钟切开;职责倾向记入 `askedKeys`。质量探针不得挡住 dasha;代表对取当前候选集。入学不再自动回答质量探针。停问词加上「没有了」等,停问且可出牌时出并列区间。不把 `occupation_note` 改成主评分事件,不打开 `confirmation_allowed`,不改 Skill `10.0.11`。
- 验证:`rectification-spoken-answer`、`rectification-server-focus`、`rectification-choice-card`、`rectification-decide-next-action`、`rectification-eight-method`、`rectification-inference-machine`、`tests/test_rectification_event_probes.py`。
- 防复发:覆盖已齐且候选并列时必须落 A/B/C/D;对比探针的 `supports/conflicts` 必须是剩余候选分钟。点选必须改后验。停问且可出牌时走 `offer_provisional_range`,不得再问已答职责题。2026-09-16 复发(BUG-912):无探针的分盘风格题借最高增益探针。2026-09-17(BUG-915):给风格题自建探针只解决了盖戳,自建探针没有像月宿边界探针那样在每个读 state 的地方重建,答题 / GET / idle 三处读持久化 receipt 都找不到它;产品同日拍板风格题不再作为计分判别题。
- 相关记录:BUG-348、BUG-350、BUG-351、BUG-366、BUG-373、BUG-374、BUG-912、BUG-915
- 防复发:覆盖已齐且候选并列时必须落 A/B/C/D;对比探针的 `supports/conflicts` 必须是剩余候选分钟。点选必须改后验。停问且可出牌时走 `offer_provisional_range`,不得再问已答职责题。2026-09-16 复发(BUG-912):无探针的分盘风格题借最高增益探针。
- 相关记录:BUG-348、BUG-350、BUG-351、BUG-366、BUG-373、BUG-374、BUG-912
- 复发自:BUG-348 / BUG-366(区分卡与覆盖≠收敛已写过,焦点持久化和剩余候选切开未接到这条会话)
- 修复版本:d7afe5b50de488d98a2ca666763784158e7c3be2
@@ -11847,15 +11847,15 @@
- 状态:resolved
- 首次发现:2026-09-16
- 最近更新:2026-09-17
- 最近更新:2026-09-16
- 影响面:`stampChoiceSchemaWithProbe`、`varga-distinguish-probe.ts`、`projectRectificationChoiceCard`、`persistNextInterviewIfIdle`
- 用户现象:正文念了感情判别题,下面没有 A/B/C/D;打字答「没有」后区间被收窄。
- 触发条件:D9 风格 followup 没有自己的 `semantic_key`,inference_state 里另有高增益 D24 对照探针。
- 根因:`stampChoiceSchemaWithProbe` 在无 preferred key 时退到 `selectHighestGainProbe`。BUG-375 只堵住了对比探针被已答教育题盖掉,没堵住「无探针的分盘风格题借最高增益探针」。
- 修复:不再退到最高增益探针。D9/D10 等风格题按剩余候选的分盘上升自建探针。GET 从落库副本投影;过期焦点 `superseded` 后落下一问。**首版只在盖戳那一刻把自建探针注入内存 state,答题 / GET / idle 三处读持久化 receipt 都找不到它(BUG-915);2026-09-17 由该单补齐重建,并按产品决策不再出风格计分题。**
- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`(2026-09-17 改写为落库形状回放);BUG-375 原断言仍绿。
- 防复发:无 `semantic_key` 的计分题不得盖上别的分盘探针。宁可不落库(走 BUG-674)也不错分。只在内存里存在的探针不算落库,新增自建探针必须同时给出「每个读 state 的地方都重建」的路径(BUG-915)。
- 相关记录:BUG-375、BUG-578、BUG-582、BUG-674、BUG-915、BUG-917
- 修复:不再退到最高增益探针。D9/D10 等风格题按剩余候选的分盘上升自建探针。GET 从落库副本投影;过期焦点 `superseded` 后落下一问。
- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`;BUG-375 原断言仍绿。
- 防复发:无 `semantic_key` 的计分题不得盖上别的分盘探针。宁可不落库(走 BUG-674)也不错分。
- 相关记录:BUG-375、BUG-578、BUG-582、BUG-674
- 复发自:BUG-375
- 修复版本:待发布
@@ -11891,50 +11891,36 @@
- 复发自:无
- 修复版本:待发布
## BUG-915 | 自建分盘探针只活在内存里:点选报 stale_probe、GET 无卡、idle 立刻 superseded
## BUG-918 | 校正时间轴读数在 375px 手机上被裁成「已…」
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`varga-distinguish-probe.ts`、`expectedAnswerSchemaFor`、`applyRectificationChoice`、`rectification-resolve-focus`、`choiceCardFromCaseDossier`、`persistNextInterviewIfIdle`、`buildMethodFollowupPlan`
- 用户现象:正文出现「亲密关系里,你更接近哪一种相处方式?」,下面四个选项灰显点不了;同屏范围行与输入框占位却写「再说一件带年月的事」。点选或打字回答会报错。
- 触发条件:BUG-912 首版给分盘风格题自建了探针;该探针只在盖戳时注入内存 state,`decision_receipt.inference_state` 里没有。
- 根因:自建探针没有按月宿边界探针的既有模式在每个读 state 的地方重建。答题路径、GET 投影、idle 过期判定都读持久化 receipt:`matchProbeForChoice` 找不到 → `stale_probe`;`liveInferenceDistinguishProbe` 找不到 → 无卡;`liveDistinguishProbe` 找不到 → 刚落库的焦点每次 idle persist 都被 `superseded`。
- 修复:新增 `withOwnedDistinguishProbes(state, receipt)`,按 `window_scan.transitions` + state 候选确定性重建全部自建探针,接入盖戳、点选答题、打字答题、GET 投影、idle 过期五处(答题两处共用 `inferenceForPersistedAnswer`);重建探针的 `source` 为专用 `owned_varga_style`,并像 `nakshatra_boundary` 一样从 contrast packet 与 event 探针池排除,避免答题写回 state 后它们在下一轮变成可问的题。产品 2026-09-17 同时拍板(选项 b):分盘风格题不再作为 `distinguish_candidates` 计分题——分层判别题只在引擎给出该领域带年份事件探针时以「某年前后有没有…」出题并按该探针计分,否则该题不出,计划直接走下一条线。删除 `attachVargaDistinguishIdentity` / `withFollowupOwnedProbe`。
- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts` 10 条:落库形状回放(未重建 = `stale_probe`,重建后 `applied` 且只按 D9 分组计分)、`choiceCardFromCaseDossier` 出卡、`persistedDistinguishFocusStale` 不误伤且候选集变化仍过期、五处接入点源码契约、(b) 决策两半。`npm test` 3421 条 / 31 红(与基线 `c32f81e7` 逐条一致)。
- 防复发:任何不在引擎 `inference_state` 里的探针,必须有一个从 receipt 确定性重建的函数,并在每个读 state 的地方接入;只在盖戳时注入内存的写法一律视为未落库。分盘风格题不得作为计分判别题。
- 相关记录:BUG-912、BUG-375、BUG-559、BUG-674、BUG-917
- 复发自:BUG-912(同一根因的第二层:探针身份有了,持久化没有)
- 修复版本:待发布
## BUG-916 | 年月阶段 host 前置排在会话校验之前
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`runV9AgentTurn`、`PUBLIC_RECTIFICATION_PHASES`
- 用户现象:无直接用户现象;跨会话(旧标签页)发来的无年月短句也会被写成一条确定性轮。
- 触发条件:BUG-910 的 host 前置写在 `dossier.case.sessionId !== sessionId` 校验之前;返回的 `phases: ["answer.host_year_entry"]` 不在公开 phase 白名单里(类型是 string,tsc 不报)。
- 根因:前置插入位置只考虑了「不要进模型」,没有沿用既有的会话校验顺序;phase 名新造而未登记。
- 修复:host 前置挪到会话校验之后(计费位置不变);phase 复用既有 `answer.host_fallback`,公开快照的 `answer_origin: "host_fallback"` 语义一致。
- 验证:`frontend/tests/rectification-v9-stream.test.ts` 等既有 host 前置断言仍绿;`npm test` 失败清单与基线逐条一致。
- 防复发:任何早退分支都必须排在会话 / Skill 身份校验之后;返回的 phase 必须是 `PUBLIC_RECTIFICATION_PHASES` 里的值。
- 相关记录:BUG-910、BUG-915
- 影响面:`frontend/src/app/globals.css` `.rectification-timeline` / `.rectification-timeline__readout` 的 `@media (max-width: 767px)` 段、`frontend/src/components/rectification-timeline.tsx`
- 用户现象:iPhone 上常驻时间轴一行读作「04:48–05:07 20 分钟 代表分钟 04:53 已…」,第四项被省略号裁断,第五项完全不显示。
- 触发条件:视口宽 375 CSS px(iPhone SE 2/3、13 mini)打开生时校正,Case 已有代表分钟、已答题数与已对照事件数(读数五项齐全)。
- 根因:两条叠加。一是读数按「四项短文本」设计,`.rectification-timeline` 在 767px 以下仍取 `calc(var(--space-4) + var(--assistant-content-inset))`,即每侧 16 + 44 = 60px,375px 的可用内容宽只剩 255px;条不在消息列里,这 44px 头像沟槽在手机上买不到任何可见对齐。二是 cfb41daf 又给读数追加了第五项 `已对照 N 件`,把四项预算变成五项。按 13px 逐字保守估宽,五项加四个 12px 间隙需要约 383px,超出 255px 约 128px,于是 `__answered` 命中自身的 `text-overflow: ellipsis` 显示为「已…」,`__dated` 被条的 `overflow: hidden` 整项裁掉。
- 修复:compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)内 `.rectification-timeline` 的 `padding-inline` 改为 `var(--space-4)` 单项;读数 `column-gap` 收到 `var(--space-2)`;`.rectification-timeline__dated` 在该段 `display: none`。桌面规则与条高(64 / 56px)一字未动,滚动锚定逻辑未动。组件仍无条件渲染全部五个 span,轴的 `aria-label` 仍由五项文本拼成,被隐藏的一项对辅助技术不丢失。
- 验证:`frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx` 6 条(渲染断言五项文本与 `aria-label` 一致;按保守逐字宽度断言 375 与 390 下四项 + 3×8px 间隙约 292px ≤ 343px / 358px,并断言旧口径 383px > 255px;compact 段声明断言去 inset、收 gap、隐藏第五项、未放开 nowrap 与条高;桌面段断言仍带 inset 与 13px)。`frontend/tests/rectification-timeline-20260909.test.ts` 的移动端 `padding-inline` 断言同步改写并附原值 / 新值 / 原因。全套 `npm test` 3420 tests / 31 fail,失败清单与基线(3414 / 31,无 Docker 集)逐条相同;`tsc --noEmit` 0 错、`npm run lint` 0 error、`next build --webpack` 后 `/` 仍 `○ Static`、首屏 gzip 620106 → 620128 字节(+0.004%)。
- 防复发:新增测试把「compact 段不得含 `--assistant-content-inset`」「第五项在 compact 下隐藏」与宽度预算写成断言;`globals.css` 与组件注释都改掉了原来「四项短文本在 375px 下还有余量」的错误说法,并写明再加一项必须重算预算而不是直接追加 span。
- 相关记录:BUG-478(跳到最新与滚动锚定归属)、BUG-919(同一次真机走查的另一条)。另记一条口径冲突待产品裁决:`frontend/DESIGN.md` §10 的禁列表明确写「已对照 N 件经历」不上条、仍归交付卡,而 cfb41daf 把它加到了条上且未改 DESIGN;本轮只在手机上把它移出可见行,桌面上是否也撤掉未擅自决定。
- 复发自:无
- 修复版本:待发布
## BUG-917 | 死掉的点选卡与「再说一件带年月的事」同屏
## BUG-919 | 「跳到最新」浮层压住选项卡最后一个可点选项
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`rectificationQuestionGapState`、`rectification-agentic-chat.tsx`、`rectification-message-entry.tsx`
- 用户现象:最后一条助手消息挂着一张灰色不可点的 A/B/C/D,同屏的范围行与输入框占位却说「再说一件带年月的事就能继续」,两条指令互相矛盾。
- 触发条件:焦点被 idle persist 判成 `superseded`(BUG-915),GET 没有 `choice_card`;`current_question` 为 null 于是 `interviewCollectWaiting` 为真。
- 根因:缺口态只看 `current_question`,看不到消息级的未答点选;消息内卡片只把 `status === "active"` 的当成未答,`superseded` 且没有作答的焦点仍然渲染成 `disabled` 的选项。
- 修复:`rectificationQuestionGapState` 新增 `unansweredDeadChoice` 输入,在 `delivered` 之后、`collect_waiting` 之前返回 `unavailable`(「没有拿到下一个问题」+「接着问」);chat 按最新一条 settled 助手消息计算该标志;message-entry 对「`superseded` 且无 `answer_option`」不再画卡。
- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`「a dead tap card never shares the screen with the collect-wait placeholder」:`collect_waiting` + 死卡 → `unavailable`,并有 chat / message-entry 源码契约。
- 防复发:死卡与 `collect_waiting` 不得同屏;未作答却已被关闭的点选题不得渲染成灰色选项。
- 相关记录:BUG-915、BUG-675、BUG-674
- 影响面:`frontend/src/app/globals.css` `.rectification-workspace__chat .conversation` / `.message-list` 的底部留白
- 用户现象:iPhone 上「跳到最新」胶囊浮在选项卡最后一行上,选项被遮掉一半,正中间不好点。
- 触发条件:校正对话滚到尾部附近、浮层可见(距底 > 96px)时,末条内容是一张多行选项卡。
- 根因:浮层 `.jump-to-latest` 是 `position: absolute; bottom: 100%` 挂在 composer 顶边上,占据 composer 上方 44px 触控高 + 自身 `space-3` = 56px 的带。而 `.message-list` 的底部留白恰好等于 `--rectification-jump-clearance`(同样 56px),与浮层带严丝合缝,末条内容顶到浮层正下方;容器 `pointer-events: none` 只放行 44px 按钮本体,按钮正好落在选项行中央,于是「遮一半 + 中间点不动」。
- 修复:`.message-list` 底部留白改为 `calc(var(--rectification-jump-clearance) + var(--space-3))`;`.conversation` 追加 `scroll-padding-block-end: var(--rectification-jump-clearance)`,让任何 scroll-into-view 也落在带之上。连同 `.conversation` 自身的 `space-4`,静止时末条下方共 84px(56px 浮层带 + 28px 空气)。浮层的位置、居中对齐与可见条件(BUG-478 的归属)一字未动,`--rectification-jump-clearance` 的取值不变(时间轴 56px 条高仍与它同源),`use-conversation-scroll-anchor` 未动。
- 验证:`frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx` 两条声明断言(留白表达式与 `scroll-padding-block-end`;浮层 `absolute` / `bottom: 100%` / `justify-content: center` / `pointer-events: none` / 44px 与 `conversationAnchorThreshold = 96` 未变)。全套门禁数字同 BUG-918。真机走查(375 / 390 宽)留在 `docs/testing/rectification-mobile-timeline-readout-20260917.md`:本机无 Chrome、无真机。
- 防复发:测试锁住「留白 = 浮层带 + space-3」这条表达式,任何把它改回等于浮层带的提交会红。CSS 注释写明了为什么不做到「任何滚动位置都不重叠」——那需要留白 ≥ `conversationAnchorThreshold`(96px) + 56px = 152px,在 667px 高的手机上等于凭空吃掉一整行选项,而浮层只在读者已经向上滚过该阈值后才出现。
- 相关记录:BUG-478(两个聊天面统一用 `useConversationScrollAnchor` 与 `JumpToLatestButton`,并把浮层改成居中)、7a4360d8(同一现象 2026-08-25 的旧修法是把胶囊右对齐,已被 BUG-478 的居中统一取代,本轮未推翻它)、BUG-918
- 复发自:无
- 修复版本:待发布
@@ -1,102 +0,0 @@
# 进度 · 自建探针只活在内存里(修复单,2026-09-17)
基线:`origin/staging` @ `c32f81e7`。分支:`codex/rectification-dead-d9-choice-fix-20260916`。
任务书:`docs/tasks/TASK-rectification-dead-d9-choice-fix-20260916.md`(§1d 已补 2026-09-17 决策记录)。
BUG:915~917(开工时 `docs/BUG_HISTORY.md` 最大号 914)。Skill **未 bump**。
## 0. 结论
| 任务 | 结果 |
| --- | --- |
| T1 `withOwnedDistinguishProbes` 五处重建(BUG-915) | 完成,三条生产路径验收断言全绿 |
| §1c 计分口径 | 按 **(b)** 落地:风格题不再是计分判别题 |
| BUG-917 死卡与 `collect_waiting` 不得同屏 | 完成(缺口态改 `unavailable`,死卡不再画灰选项) |
| T2 BUG-916 host 前置挪到会话校验之后 + phase 登记 | 完成(phase 让步为复用 `answer.host_fallback`) |
| T3 记录 | BUG-912 改 `resolved` 并补首版缺陷;BUG-915~917 新增;BUG-375 复发段补一行;CHANGELOG / 真机清单 / 本进度 |
## 1. 做了什么
### T1 · 自建探针按 receipt 确定性重建(BUG-915)
- `frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts`
- 新 `withOwnedDistinguishProbes(state, receipt)`:按 `OWNED_DISTINGUISH_QUESTIONS`(七条分层题,`<method_id>:<ask_theme>`)× `receipt.window_scan.transitions`(缺失时退到 `state.transitions`)× `state.candidates` 重建所有自建探针,同 id / 同 semantic_key 不重复,无变化时原样返回同一对象。不依赖「当前哪个 followup 在问」。
- 重建探针的 `source` 是专用的 `owned_varga_style`,`decision-from-dossier.ts` 的 `contrastPacketFromLatestResult` / `rectificationFollowupCatalog` / `contrastPacketFromState` 三处像排除 `nakshatra_boundary` 一样把它排除:答题会把重建后的 state 写回 receipt,若不排除,这些探针会在下一轮变成 contrast / event 候选,等于绕开 (b)。
- 删掉 `withFollowupOwnedProbe`(只在盖戳那一刻往内存 state 注入,是本单 P0 的根因)与 `attachVargaDistinguishIdentity`(风格计分题的生产入口,按 (b) 不再需要)。保留 `buildVargaDistinguishFields` + `conflictProbeFromFollowup` 供重建与将来 (a) 使用。
- 接入点(五处,全部写成源码契约断言):
1. `server-focus.ts` `expectedAnswerSchemaFor` 盖戳;
2. `answer-choice.ts` `applyRectificationChoice` 点选答题;
3. `interview-state.ts` `choiceCardFromCaseDossier` 的 GET 投影(`persistedDistinguishCard` 用的就是这个 state);
4. `answer-choice.ts` `persistNextInterviewIfIdle` 的 `inferenceForStale`;
5. `src/mastra/rectification-v9-tools.ts` `rectification-resolve-focus`(打字回答同一焦点的路径)。
- 2 与 5 共用新导出的 `inferenceForPersistedAnswer(receipt)`,测试直接调它,保证断言走的是生产代码而不是测试自己拼的 state。
- idle 的过期判定抽成导出的 `persistedDistinguishFocusStale(inference, focus)`(逻辑与原来逐字一致,只是可被测),BUG-559 的 split-hash 判定原样保留。
**为什么没有在 `previousInferenceFromReceipt` 出口统一注入**(任务书 §2 首选、§3 让步 1 允许退成逐点包装):那个出口的返回值同时喂给 `contrastPacketFromLatestResult` 与 `rectificationFollowupCatalog` 的 `eventProbes`(`decision-from-dossier.ts:226/384`)。自建风格探针一旦进那两条投影,就会作为 `yearless` / `contrast` 候选重新变成可问的题,等于绕开本单的 (b) 决策,并且会改动与本单无关的决策面。因此按让步 1 逐点包装,并用源码契约测试钉住五处。
### §1c 计分口径按 (b) 落地
- `method-followup.ts` `makeFollowup`:`varga_observation` / `precision_stage` 的分层判别题(`d9_relationship` / `d10_career` / `d4_home` / `d5_education` / `relatives` / `d2_finance` / `d30_health`,且自己没有 `semantic_key`)
- 若引擎在该领域还有**带年份**事件探针(`year > 0`、`choice_kind` 非 `varga_style` / `event_quality`、未在 `askedProbeKeys` / `answeredProbes` 里),就绑定它:题面是「某年前后有没有…」,`semantic_key` / `expected_outcomes` / `probe_year` 全部取该探针,按该探针计分;探针没有 `semantic_key`(盖不上戳)时不绑定身份,维持基线行为(落不了库走 BUG-674 耗尽分支,不会错分)。
- 没有可用事件探针时**不出题**:不挂 `choice_frame`,由既有 `takeRenderableDistinguish` 记 `frameless_distinguish` 后计划继续往下走(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。
- `lagna_frame`(`dasha_events`,`domain` 为 null)不在这条规则里,行为与基线一致。
- 实测对照(同一输入,同机):基线 `next_followup` = `d9_relationship` / `precision_stage` / `choice_kind=varga_style` / 题面「亲密关系里,你更接近哪一种相处方式?」;本单同输入 `next_followup = null` + `dropped_probes` 两条 `frameless_distinguish:d9_relationship`,即 09-17 真机那道题不会再出现。
### BUG-917 · 死卡不得与 `collect_waiting` 同屏
- `rectification-surface-state.ts`:`RectificationQuestionGapInput` 新增 `unansweredDeadChoice`,在 `delivered` 之后、`collect_waiting` 之前返回 `unavailable`(「没有拿到下一个问题」+「接着问」),占位与范围行随之同步。
- `rectification-agentic-chat.tsx`:按最新一条 settled 助手消息计算 `unansweredDeadChoiceOnMessages`(kind 为 `choice` / `reverse_verify`、无 `answer_option`、status 为 `active` 或 `superseded`、且没有活卡)并传进缺口态。
- `rectification-message-entry.tsx`:`unansweredDeadChoice` 补上「status `superseded` 且没有作答」——这类焦点没有答案可展示,原来会画成 `disabled` 的灰色 A/B/C/D(09-17 手机截图那张)。
### T2 · BUG-916
- `agent-run.ts`:年月阶段 host 前置(`shouldHostReaskYearEntry`)挪到 `dossier.case.sessionId !== sessionId` 校验之后,跨会话消息不再被写成确定性轮;计费位置不变。
- `phases` 从未登记的 `answer.host_year_entry` 改成既有 `answer.host_fallback`(任务书 §3 让步 2),公开快照的 `answer_origin: "host_fallback"` 语义也对得上。
## 2. 门禁数字
| 项 | 基线 `c32f81e7` | 本单 |
| --- | --- | --- |
| `./node_modules/.bin/tsc --noEmit` | 0 错 | **0 错** |
| `npm run lint` | 0 error / 116 warning | **0 error / 116 warning** |
| `npm test` | tests 3414 · pass 3368 · fail 31 · skipped 15 | tests **3421** · pass 3375 · fail **31** · skipped 15 |
| `npm test` 失败清单 | 31 条无 Docker 既有缺口 | **`diff` 逐条一致**(见下) |
| `next build --webpack` `/` 渲染标记 | `○ Static` | **`○ Static`** |
| 产物 JS gzip 合计(`.next/static/**/*.js`) | 1,496,339 字节 | **1,496,408 字节(+0.005%)** |
| `pytest tests/test_rectification_refinement_packet.py tests/test_rectification_event_probes.py tests/test_rectification_engine_memoization.py` | 63 绿 | **63 绿** |
| `run_quality_gate.py --profile quick` | 卡在末尾 `npm test`(31 条无 Docker 失败) | 同:pytest 段 **798 passed / 1 skipped**,末尾 `npm test` 步骤 exit 1(同样的 31 条) |
gzip 口径:本仓没有首屏 gzip 测量脚本,Next 16 的路由表也不再输出 Size 列;取 `.next/static` 下全部 js chunk 的 gzip 字节合计,基线与本单同机同命令(`next build --webpack`)各测一次。
失败清单比对:`diff` 基线与本单的 `not ok` 名单为空输出。31 条全部是无 Docker 的既有环境缺口(数据库 / 迁移 / 部署 / 缓存 appendix / ephemeris 等),与 `BLOCKED.md` 一致。
测试总数 3414 → 3421:本单测试文件由 4 条改写为 11 条(见下)。没有弱化任何既有断言。
### 改过的既有断言
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/rectification-dead-d9-choice-20260916.test.ts` | `GET projects the persisted distinguish copy when its probe is still live`:手工把自建探针塞进 `buildInferenceState({probes:[D24, 自建]})` 再断言有卡 | 删除该 fixture;改成 `choiceCardFromCaseDossier` 读 `{inference_state: 只含 D24, window_scan}` 的落库形状 | 任务书 §1:手塞探针的 state 不是生产形状,正是它让首版「测试全绿、生产报错」 |
| 同上 | `year-stage D9 followup does not borrow the D24 contrast probe`:答题用 `withFollowupOwnedProbe(inference, followup)` | 拆成两条:盖戳断言 + 用生产的 `inferenceForPersistedAnswer(receipt)` 答题,并先断言未重建时确实 `stale_probe` | `withFollowupOwnedProbe` 已删除;答题必须走生产读 state 的那一段 |
其余既有断言一字未改。
### 本单测试(`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`,11 条全绿)
1. 盖戳按 receipt 重建自建探针、不借 D24(并断言落库 state 里确实没有该探针)。
2. 生产答题路径:同一 schema 在未重建的 state 上 `stale_probe`,在 `inferenceForPersistedAnswer` 上 `applied`,`answered_probes` 末位 `semantic_key=d9_relationship:relationship_style`,不含 D24。
3. `choiceCardFromCaseDossier` 对该落库焦点返回卡,`question_id` 与焦点一致、四个选项。
4. `persistedDistinguishFocusStale`:未重建判 stale(这就是误伤),重建后不 stale;候选集变化后仍 stale(BUG-559 保留)。
5. 五处接入点的源码契约。
6. (b) 决策:无带年份事件探针时不出风格题(题面不含「更接近哪一种」,`dropped_probes` 有 `frameless_distinguish:d9_relationship`)。
7. (b) 决策的另一半:引擎给出 2020 年感情事件探针时该线照问,`semantic_key` 为该探针、`choice_kind=existence`、题面含年份。
7b. 重建出来的探针不得变成可问的题:`rectificationFollowupCatalog` 的 `eventProbes` 与 `contrastPacket.probes` 里都不含该 key。
8. BUG-917:`collect_waiting` + 死卡 → `unavailable`;chat 与 message-entry 的源码契约。
9. BUG-913 `closed_by_design` 复核(原样保留)。
10. BUG-914 两极不得同时进 `shared_traits`(原样保留)。
## 3. 环境缺口
- 无 Docker:31 条失败与基线逐条一致;`run_quality_gate.py --profile quick` 因此仍停在末尾 `npm test`。
- 无登录态 / 无 Chrome / 无模型凭据:浏览器级验收写成 `docs/testing/rectification-dead-d9-choice-fix-20260916.md`,未声称通过。
- staging 部署核对(`/api/health` 的 `deployment.gitCommit`)不在本单能力范围:只推分支,未推 `staging`。
- 事发 Case 的 04:48–04:59 仍建立在错分答案上,不要采用(与上一单结论相同)。
@@ -0,0 +1,94 @@
# 进度记录 · 校正页移动端:时间轴读数被裁、「跳到最新」压住选项(2026-09-17)
任务书:`docs/tasks/TASK-rectification-mobile-timeline-readout-20260917.md`
分支:`codex/rectification-mobile-timeline-readout-20260917`,基线 `origin/staging` = `c32f81e7`(任务书写的 `8d7dfbf0` 已被 staging 超车,按指令取 `c32f81e7`)。
BUG:918、919。纯前端样式,未跑 Python 预检(AGENTS §9 允许)。
未触碰(并行会话所有):`frontend/src/components/rectification-agentic-chat.tsx`、`frontend/src/lib/rectification-agentic/**`、Python、Skill。也未动时间轴固定高度(64 / 56px)与 `use-conversation-scroll-anchor`。
## 1. 做了什么
| 任务 | 文件 | 改动 |
| --- | --- | --- |
| T1 BUG-918 | `frontend/src/app/globals.css` | `@media (max-width: 767px)` 段:`.rectification-timeline` 的 `padding-inline` 由 `calc(var(--space-4) + var(--assistant-content-inset))` 改为 `var(--space-4)`;`.rectification-timeline__readout { column-gap: var(--space-2) }`;`.rectification-timeline__dated { display: none }`。桌面段未动。同时改掉基线注释里「四项短文本在 375px 下还有余量」这句被实证推翻的话。 |
| T1 BUG-918 | `frontend/src/components/rectification-timeline.tsx` | 只加注释:五个 span 永远无条件渲染,由 CSS 决定窄屏显示几项;轴 `aria-label` 仍由五项拼成;再加第六项必须重算宽度预算。无行为改动。 |
| T2 BUG-919 | `frontend/src/app/globals.css` | `.rectification-workspace__chat .message-list` 底部留白由 `var(--rectification-jump-clearance)` 改为 `calc(var(--rectification-jump-clearance) + var(--space-3))`;`.conversation` 追加 `scroll-padding-block-end: var(--rectification-jump-clearance)`。`--rectification-jump-clearance` 取值未变(时间轴 56px 条高仍与它同源,既有断言不受影响);浮层的位置、居中、可见条件未动。 |
| T3 | `docs/BUG_HISTORY.md`、`CHANGELOG.md`、`frontend/DESIGN.md`、`docs/testing/…`、本文件 | 记录。 |
## 2. 宽度预算(T1 的算术)
jsdom / happy-dom 不在本套件里,`scrollWidth` 与 `clientWidth` 都量不到,所以这是**按逐字保守估宽的算术**,不是测量。每字宽度一律向上取整(数字取 0.6em,而 -apple-system / Helvetica 的 tabular 数字约 0.556em),font-size 13px。测试里 `readoutWidthPx` 就是这套系数。
单项估宽(px):`04:48–05:07` 77.5、`20 分钟` 45.2、`代表分钟 04:53` 91.1、`已答 6 题` 54.1、`已对照 3 件` 67.1。
| | 改前 | 改后 |
| --- | --- | --- |
| compact 每侧内边距 | 16 + 44 = 60px | 16px |
| 375px 可用内容宽 | 255px | 343px |
| 可见项数 / 列间距 | 5 项 / 12px | 4 项 / 8px |
| 读数需要宽度 | 383.0px | 291.9px |
| 375px 余量 | **−128.0px(被裁)** | **+51.1px** |
| 390px 余量 | −113.0px | +66.1px |
| 320px 余量 | −143.0px | −3.9px |
375 与 390 两个目标宽度都放得下。**320px 差约 4px**:按系统实际字宽(数字 0.556em)重算是 283.3px ≤ 288px,能放下;按上面的保守系数差一点点。真机 320px 若尾部被裁一点属已知边界,已写进 `docs/testing/` 清单第「已知边界」段,范围和代表分钟在任何情况下都不会被裁。
让步顺序:没有用到。任务书让步 1(只做去 inset + 缩 gap)算下来是 5 项 335.0 + 4×8 = 367.0px > 343px,仍然放不下,所以第五项必须离开可见行;采用了任务书 T1 里的「只保留一项」分支,而不是合并成「N 题 · N 件」——合并需要原始计数,而计数只以 `已答 6 题` / `已对照 3 件` 这种成品字符串到达组件,要么去改 `frontend/src/lib/rectification-timeline-scale.ts`(超出本单文件范围),要么在组件里正则抠数字(脆弱)。隐藏这一项另有 DESIGN 依据,见第 4 节。
## 3. 「跳到最新」的几何(T2)
浮层占 composer 上方 56px 的带:按钮 44px + 浮层自身 `padding-bottom: var(--space-3)`。基线 `.message-list` 底部留白恰好也是 56px,与带严丝合缝,末条内容顶到按钮正下方;容器 `pointer-events: none` 只放行 44px 按钮本体,而按钮居中,正落在选项行中央 → 「遮一半 + 中间点不动」。
改后:`.message-list` 68px + `.conversation` 16px = 静止时末条下方 84px = 56px 带 + 28px 空气。
**做不到、也没有假装做到的部分**:把「任何滚动位置都不重叠」做成保证,需要留白 ≥ `conversationAnchorThreshold`(96px) + 56px = 152px。推导:设末条内容底为 E、尾部留白 P、距底 d,浮层带覆盖内容坐标 `[E+P-d-56, E+P-d]`,与真实内容重叠 ⟺ `d > P - 56`;而浮层可见 ⟺ `d > 96`。所以要让「浮层刚出现的那一刻」不压内容就得 `P - 56 ≥ 96`。152px 在 667px 高的手机上等于凭空吃掉一整行选项,且浮层只在读者已经主动向上滚过 96px 后才出现,所以按 CSS 注释记录理由后不做。本轮保证的是:**贴底静止时(浮层隐藏)末条选项整行可点且与输入框有 28px 空隙**;浮层可见时它浮在已滚上去的内容上,与两个聊天面一致,点一下即回到底。
未改浮层可见条件(任务书要求),也未把胶囊改回右对齐——`tests/rectification-agentic-entry.test.ts` 锁着 `justify-content: center`(BUG-478 的统一决策),右对齐是 2026-08-25 `7a4360d8` 的旧修法,已被取代,不擅自推翻。
## 4. 顺带发现的口径冲突(未擅自裁决,需产品拍板)
`frontend/DESIGN.md` §10 的「以下一律不得加回来」表里明确写着「已对照 N 件经历」不上条、仍归交付卡,理由是它和「已答 N 题」不是同一个量。但 `cfb41daf`(2026-09-16,BUG-740~743)把 `datedEventLabel` 加到了读数上,且没有改 DESIGN。也就是说这第五项从进条那天起就与 DESIGN 的红线矛盾,而它正是压垮 375px 宽度预算的那一项。
本轮**只在 767px 以下把它移出可见行**(任务书授权范围内),桌面保留原状,并把这处冲突写进 BUG-918 的「相关记录」、DESIGN §10 和真机清单,等产品决定桌面是否也撤掉。没有单方面改桌面。
## 5. 测试
新增 `frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx`(6 条):
1. 渲染断言:五项文本按序全在 DOM,且轴 `aria-label` 含全部五项。
2. 宽度预算断言:compact 可见四项 + 3×8px ≤ 343px(375)与 ≤ 358px(390);并反向断言旧口径 383px > 255px,把 Bug 的形状钉住。
3. compact 段声明断言:`padding-inline: var(--space-4)` 且段内不含 `--assistant-content-inset`;`column-gap: var(--space-2)`;`__dated` `display: none`;段内不得出现 `__range` / `__width` / `__working`(这两个数字任何宽度都不许隐藏或缩);不得放开 `flex-wrap` / `white-space`;条高仍 56px。段的切取是按大括号配平取出那一个 `@media` 块,不是 `slice` 到文件尾。
4. 桌面段断言:仍是 `calc(var(--space-8) + var(--assistant-content-inset))`,读数仍 `nowrap` + 13px。
5. T2 断言:留白表达式、`scroll-padding-block-end`。
6. 浮层未动断言:`absolute` / `bottom: 100%` / `justify-content: center` / `pointer-events: none` / 按钮 44px,以及 `conversationAnchorThreshold = 96` 与 anchor hook 里不出现 scroll-padding。
文件头写明了「本套件没有 DOM 实现,所以几何断言是算术不是测量,真机走查必做」。
改写的既有断言 1 条 —— `frontend/tests/rectification-timeline-20260909.test.ts`「the mobile bar stays within the 44px touch rhythm」:
- 原值:`assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/)`
- 新值:`assert.match(mobile, /padding-inline: var\(--space-4\);/)`
- 原因:BUG-918。这条断言锁的正是造成裁切的那 44px 头像沟槽。条不在消息列里,手机上对齐头像列没有可见收益;同一测试里桌面那条 inset 断言未动,`--rectification-jump-clearance` 断言未动。
没有弱化其它任何断言,没有删测试。
## 6. 门禁数字
| 项 | 基线(`c32f81e7`) | 改后 |
| --- | --- | --- |
| `./node_modules/.bin/tsc --noEmit` | — | **0 错** |
| `npm run lint` | — | **0 error**(116 warning,与基线同为既有 `no-unused-vars`,未顺手修) |
| `npm test` | 3414 tests / 31 fail | **3420 tests / 31 fail**(+6 新测试全绿) |
| 失败清单 | 31 条无 Docker 集 | **逐条相同**(排序后 `diff` 无输出) |
| `next build --webpack` | exit 0 | **exit 0** |
| `/` 路由 | `○ Static` | **`○ Static`** |
| 首屏 gzip | 620,106 B / 25 文件 | **620,128 B / 25 文件(+22 B, +0.004%)** |
- 失败清单 31 条全部是既有无 Docker / 无凭据集(database-*、staging sync、billing、ephemeris 401 等),与 `BLOCKED.md` 一致。
- gzip 口径:把 `.next/server/app/index.html` 里引用到的每个 `/_next/static/*.js|css` 单独 `gzip -9` 后求和。唯一变化是首屏 CSS 分块 36,510 → 36,532 B。
- Turbopack 拒绝软链 `node_modules`,按环境说明用 `--webpack`。
## 7. 环境缺口
- **无 Chrome、无真机**:375 / 390 宽下「读数四项完整」「胶囊不压选项」只能人工确认,清单在 `docs/testing/rectification-mobile-timeline-readout-20260917.md`(7 条 + 已知边界)。第 2 节的宽度是算术,不是真机测量;第 3 节的 28px 空隙同样没有真机截图。
- **未部署**:本分支只推自己的分支,未推 staging,`deployment.gitCommit` 未核对。
+2 -2
View File
@@ -103,8 +103,8 @@
| `TASK-rectification-collection-redesign-20260910.md` | `PROGRESS-rectification-collection-redesign-20260910.md` | 产品口径:用户先说、系统从已说的事锚定追问、不得用生日推年份、不得说「领域」、永远交付结果。无固定题数:收集问题池按信息价值排序问到训练门开;选择题问到收敛门开或增益见底;门关不出结果只出精确缺口句并保持开放;≥4 件才留 holdout;撤回 BUG-642 年份线索、BUG-646 单终态与「暂定采用」;Skill 10.0.23(BUG-646~648) | 待验收 | `dd8f35f7` |
| `TASK-rectification-year-focus-overlay-20260916.md` | `PROGRESS-rectification-year-focus-overlay-20260916.md` | 真机:定向健康题选 A 后打字给年月,助手追问「是你本人做的吗」,追问句下面挂着录入卡(`set-focus` 同号覆盖年月焦点题干、`event_date_entry` 仍在,BUG-908);事件入账后年月焦点没关(BUG-909,回放定 resolve 还是计划层);答短句撞「本轮达到步骤上限」(BUG-910,host 前置接无年月短句,先 investigating);persisted 问题块无 `--assistant-content-inset`(BUG-911)。**产品追加拍板:「哪一类事 + 发生年月」录入卡整个删掉,年月阶段直接打字(推翻 precision-gate 单 T3 与 SKILL.md:84)**。主体默认本人不追问,Skill 10.0.29 | 已验收 | `6aabbe38`(BUG-908~911,Skill 10.0.29):tsc 0 / lint 0 error / npm test 3414 条 31 红与基线 05cf20fb 逐条相同 / `/` Static / 首屏 gzip 620,502→620,107(−0.06%)/ 快速门 Python 段 798 绿。两条小项(host 前置排在会话校验之前、`answer.host_year_entry` 未登记)并入 dead-d9 修复单 T2 |
| `TASK-rectification-dead-d9-choice-20260916.md` | `PROGRESS-rectification-dead-d9-choice-20260916.md` | 真机(6aabbe38):删卡流程已通,但**P0** D9 感情题没有自己的探针,`stampChoiceSchemaWithProbe` 退到最高增益探针,盖上了 **D24 学业六星座对照探针**(BUG-375 (1) 复发):答「没有」按 D24 计分,04:48–04:59 收窄不可信;GET 投影又匹配不上 → 无卡(BUG-912);打字答「没有」后平局直接出交付卡待回放定性(BUG-913);`NAKSHATRA_TRAITS` 整对塞进一个 option,卡头把相反两极列成共同点(BUG-914)。**串行在 year-focus-overlay 验收之后** | **验收未通过(P0)** | `227a7571`+`8d7dfbf0`:不借探针、一列一句、BUG-913 定性均过;但自建探针只在盖戳时注入内存 state,生产答题路径读持久化 state → `stale_probe` 报错、GET 无卡、idle 立刻 superseded(生产路径回放实证)。修复单见下一行 |
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已实现待验收 | `codex/rectification-dead-d9-choice-fix-20260916`(BUG-915~917,Skill 未 bump):`withOwnedDistinguishProbes` 五处重建(盖戳 / 点选答题 / 打字答题 / GET 投影 / idle 过期;未在 `previousInferenceFromReceipt` 出口统一注入——会把自建探针喂进 contrastPacket 与 eventProbes,按让步 1 逐点包装 + 源码契约测试);按 (b) 删掉风格计分题入口,分层判别题只在有带年份事件探针时出题;死卡改走 `unavailable` 修复出口。tsc 0 / lint 0 error 116 warning / npm test 3421 条 31 红与基线 `c32f81e7` 逐条相同 / `/` `○ Static` / 产物 JS gzip 1,496,339→1,496,408(+0.005%)/ pytest 63 绿、快速门 Python 段 798 绿 |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 待领取 | — |
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过** | 待领取 | — |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已实现待验收 | `codex/rectification-mobile-timeline-readout-20260917`:纯 CSS + 注释 + 测试。T1 compact 段去掉 44px 头像缩进、`column-gap` 收到 `space-2`、第五项 `已对照 N 件` 在 767px 以下 `display: none`(仍在 DOM 与轴 `aria-label` 里)——**让步 1 算下来仍放不下**(去 inset + 缩 gap 后 5 项需 367px,375px 只有 343px 可用),故走 T1 的「只保留一项」分支;桌面段、条高 64/56px、滚动锚定全未动。T2 `.message-list` 底部留白改 `calc(clearance + space-3)`(静止时 84px = 56px 浮层带 + 28px 空气)+ `.conversation` `scroll-padding-block-end`;浮层位置/居中/可见条件未动。tsc 0 / lint 0 error / `npm test` 3420 条 31 红与基线 `c32f81e7`(3414 / 31)逐条相同 / `/` 仍 `○ Static` / 首屏 gzip 620,106 → 620,128 B(+0.004%)。**顺带发现口径冲突待产品拍板**:DESIGN §10 禁列表写明「已对照 N 件经历」不上条,`cfb41daf` 却把它加上了且没改 DESIGN,本轮只在手机上移出可见行。无 Chrome / 无真机,375 与 390 宽的读数完整性与浮层不遮选项留 `docs/testing/rectification-mobile-timeline-readout-20260917.md`(7 条 + 320px 已知边界) |
### 聊天主链路与首页
@@ -34,12 +34,6 @@
- **本单默认按既有口径执行(b)**:分盘风格题不得作为 `distinguish_candidates` 全额计分。D9/D10 判别题只在引擎给出该领域带年份事件探针(`discriminating_event_probes` / `prospective_probes`)时以「某年前后有没有…」出题并计分;没有事件探针时不出风格题,直接走下一条线(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。风格描述只留在既有的平局参考题(±1)与交付卡列。产品若改口为 (a)「允许风格题全额计分」,在本单决策记录追加一行即可,执行方不得自行选边。
- 这条不改变 T1 的重建要求:事件探针来自引擎 state,本就在持久化里;自建探针的重建逻辑仍要做,供 (a) 与将来使用,但默认路径不再产生风格计分题。
## 1d. 决策记录(2026-09-17,产品负责人)
- **§1c 采用 (b)**:分盘风格题不得作为 `distinguish_candidates` 全额计分题。D9/D10 等分层判别题只在引擎给出该领域带年份事件探针时才出题(题面是「某年前后有没有…」,按该事件探针计分);没有事件探针时该题不出,计划直接走下一条线(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。风格描述只留在既有的 ±1 平局参考题与交付卡列。
- T1 的重建要求照做:`withOwnedDistinguishProbes` 在每个读 state 的地方按 receipt 确定性重建,保证 (b) 决策之前已落库的风格焦点可答、有卡、不被 idle 误 supersede。
- 执行方据此删除了「给风格题自建探针再出题」的生产入口(`attachVargaDistinguishIdentity` / `withFollowupOwnedProbe`),保留 `buildVargaDistinguishFields` + `conflictProbeFromFollowup` 供重建与将来 (a) 使用。
## 2. 修法(照 `withNakshatraBoundaryProbe` 的既有模式)
月宿边界探针也不在引擎 state 里,它的做法是三处都从 receipt 重建:`server-focus.ts:124`(盖戳)、`answer-choice.ts:591`(答题)、`inference-adapter.ts:235`(`previousInferenceFromReceipt` 之后)。自建分盘探针照抄:
@@ -1,15 +0,0 @@
# 真机清单 · 风格题不再计分、死卡不再同屏(2026-09-17)
在 staging 已登录账号上走,用公开名人或虚构出生资料。上一单事发那段 04:48–04:59 **不要采用**(建立在错分上):补一件带年月的事触发重算,或新开一段校正。
自动化做不到的部分(无登录态、无 Chrome、无模型凭据)都在这里。前六条针对本单改动,第 7 条是旧问题的守门。
1. **风格题不再出现**:走到采集中段(已给 3~6 件带年月的经历),继续答题直到助手换线。正文里不得再出现「亲密关系里,你更接近哪一种相处方式?」「事业上你更接近哪一种」这类只讲性格、不讲年份的判别题。
2. **该问的还在问**:关系 / 事业 / 家人这些线仍然会以「20XX 年前后,有没有…」的形式出现,题干下面必须有可点的 A/B/C/D,点一下要有反应(出现「已记录…」并更新范围)。
3. **没有可点的题时的样子**:如果某一轮拿不到下一个问题,屏幕上应当是「没有拿到下一个问题」+「接着问」按钮,输入框占位是通用的「继续说你记得的人生经历,或回答刚才的问题…」。**不得**出现「灰色的四个选项」与「再说一件带年月的事」同屏。
4. **旧的死卡**(只有 09-16~09-17 之间做过校正的账号能复现):打开那段旧校正,最后一条助手消息里那张灰色四选项应当不再出现;缺口处是「没有拿到下一个问题」+「接着问」。点「接着问」应当拿到新的一问。
5. **手机上重看一遍第 3、4 条**:范围行、输入框占位、缺口提示三处文案必须彼此一致(09-17 的问题是在手机上看出来的)。
6. **跨会话不落轮**:在一台设备上打开同一段校正的旧标签页,另一台继续对话;旧标签页里发一句没有年月的短句(例如「记不清了」),不应当在新会话里多出一条助手回复;应当报会话不匹配或要求刷新。
7. **交付卡守门(上一单)**:若出三列交付卡,每一列性格只有一句;卡头共同点里不得同时出现一对相反的话(例如「希望两边都能说得过去」和「必要时会直接选边」)。
发现异常时请抄下:屏幕上那句话的原文、`GET /api/rectification/cases/<caseId>` 里的 `interview.current_question`、`choice_card`、`interview.session_outcome`、`stop_reason`(去掉出生资料再贴)。
@@ -0,0 +1,18 @@
# 真机清单 · 校正页移动端时间轴读数与「跳到最新」(2026-09-17)
在 staging 用受控账号、真机(不是桌面浏览器缩窗)走一遍生时校正。本机无 Chrome、无真机,以下全部做不了自动化,只能人工确认。不要把真实出生资料写进任何记录。
优先 375 CSS px(iPhone SE 2/3、13 mini),再复查 390(iPhone 12–16 基础款)。若手上只有 320 宽的老机(SE 1 代),第 2 条按「可接受被裁一点」看,见末尾说明。
1. **读数四项完整。** 进入校正、答到出现代表分钟之后,看上方那条时间轴第一行。应完整读出四项:`HH:MM–HH:MM`、`N 分钟`、`代表分钟 HH:MM`(或 `已采用 HH:MM`)、`已答 N 题`。**任何一项都不得出现省略号或半个字**,尤其不得再看到「已…」。
2. **读数不换行、条不变高。** 这一行必须是一行。条的高度在整个流程里不得变化(56px),末尾不得出现第二行或把下面的刻度挤出去。横屏、放大字号(iOS 设置 → 显示与亮度 → 文字大小最大档)各看一次:这两种情况下允许尾部被裁,但**范围和代表分钟必须仍然完整可见**。
3. **左边距与页面同源。** 读数和刻度的左起点应与页面左边距一致(约 16px),不再比正文多缩进一个头像位。桌面(宽 ≥ 768px)打开同一条校正,读数应仍然与助手正文左边线对齐,并且多出第五项「已对照 N 件」——手机上没有这一项是预期的。
4. **读屏软件仍读到五项。** 打开 VoiceOver,聚焦到时间轴的轴本体(`role="img"`),朗读内容应包含范围、宽度、代表分钟、已答题数**和已对照件数**,即手机上看不见的那一项在朗读里仍然存在。
5. **「跳到最新」不压选项。** 出现一张多行选项卡(判别题 A/B/C/D 或交付卡三列)之后,向上轻扫一段让「跳到最新」胶囊出现,再滚回底部。滚到底时:胶囊应消失,选项卡最后一行与输入框之间应有明显空隙(约 28px),最后一个选项**整行可点,中间也能点中**。
6. **胶囊出现时仍能操作。** 胶囊可见(已向上滚过一段)时点它,应直接回到最新内容,且回到底之后立刻能点最后一个选项。胶囊本身仍是居中的胶囊、44px 高、有阴影——位置和出现条件本轮没有改,不应有变化。
7. **回归面:普通对话不受影响。** 打开一段普通咨询对话(不是校正),向上滚出「跳到最新」,确认它的位置、外观和底部留白与改动前一致(本轮只改了校正面的留白)。
## 已知边界
- **320px 宽(iPhone SE 1 代 / iPhone 5s 一档)**:按逐字保守估宽,四项 + 间隙约 292px,可用约 288px,差约 4px。按系统实际字宽算是放得下的,但如果真机上「已答 N 题」尾部被裁一点,属已知边界而非回归——范围和代表分钟仍必须完整。看到就记进进度记录,不要当新 Bug 开单。
- **「已对照 N 件」是否也该从桌面撤掉**:`frontend/DESIGN.md` §10 的禁列表说这一项归交付卡、不上条,而代码后来把它加到了条上。本轮只在手机上把它移出可见行,桌面保留原状,等产品裁决。见 `docs/BUG_HISTORY.md` BUG-918。
+8 -5
View File
@@ -226,7 +226,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
### Jump to latest
- **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold.
- **Visibility:** shown when the reader is not anchored — more than 96px from the bottom after scrolling up — and hidden again once they return or press it. Sending a question re-anchors.
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
@@ -256,8 +256,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `question-gap`, persisted question | only when no assistant message can carry the 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`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap. Never while the latest assistant message still carries an unanswerable tap question — that is the repair exit below (BUG-917) | enabled, placeholder “再说一件带年月的事” |
| `question-gap`, dead card on the last message | the latest assistant message carries a tap question with no answer and no live card (its focus was superseded, or GET has no card): the card is not drawn at all, and the gap is the repair exit “没有拿到下一个问题。” + “接着问”. A greyed, unclickable A/B/C/D never appears next to the collect-wait placeholder | enabled, generic placeholder |
| `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap | enabled, placeholder “再说一件带年月的事” |
| `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | 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 |
@@ -635,14 +634,18 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px;56px 等于既有的 `--rectification-jump-clearance`(44px 触控尺寸 + `space-3`),不新造常数。
读数和轴的水平内边距对齐助手列:桌面 `space-8 + --assistant-content-inset`,767px 以下 `space-4 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
读数和轴的水平内边距在桌面对齐助手列:`space-8 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。**767px 以下只取 `space-4`,不加头像缩进**(BUG-918):条不在消息列里,手机上对齐头像列买不到任何可见收益,而那 44px 每侧要吃掉 375px 里的 88px,读数因此被裁。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。读数四项、一行排完:
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。手机上读数四项、一行排完:
`05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题`
**读数只增宽,不增高。** 条高仍是硬规定二的 64 / 56px,`.rectification-timeline__readout` 因此是 `flex-wrap: nowrap` + `white-space: nowrap`;真放不下时由条本身的 `overflow: hidden` 裁掉尾部,四项里「已答 N 题」排最后、最先被裁——它是背景信息,区间和代表分钟不是。
**一行是有宽度预算的,767px 以下必须按 375px 算,不是「应该放得下」。** BUG-918 的实证:13px 下四项 + 头像缩进已超 375px,第四项被自身 `text-overflow: ellipsis` 显示成「已…」。因此 compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)做三件事:`padding-inline` 去掉头像缩进、读数 `column-gap` 收到 `space-2`、`.rectification-timeline__dated` `display: none`。按逐字保守估宽,手机上四项 + 3×8px 间隙约 292px,375px 可用 343px、390px 可用 358px;320px 只余约 −4px,靠条自身 `overflow: hidden` 兜底。**再往条上加一项,先重算这笔预算,不是直接追加一个 span。** 组件永远无条件渲染全部 span,隐藏只在 CSS 里发生,轴的 `aria-label` 仍由全部标签拼成,读屏软件不因视口宽度丢内容。
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-agentic-chat.tsx` 算一次、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。
**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。
+52 -5
View File
@@ -2538,9 +2538,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
/* One line, always. The bar's height is fixed on purpose (see above), so the
readout may grow wider but never taller; wrapping would push the axis out of
a box nothing is observing. Four short items clear 375px with room to spare,
and the bar's own `overflow: hidden` clips rather than reflows if they ever
do not. */
a box nothing is observing. The bar's own `overflow: hidden` clips rather
than reflows if the row ever does not fit.
This used to claim "four short items clear 375px with room to spare". They
did not even at four, and a fifth was added later (BUG-918). At 13px, with
the avatar-column inset, a 375px phone left 255px of content box: the four
items needed about 304px, the five about 383px. So `已答 N 题` came through
as `已…` and `已对照 N 件` never appeared at all. The compact block below is
what makes the row fit — it drops the inset and the fifth item there, for
about 292px against 343px. Anything added here has to be re-measured against
that budget, not assumed to fit. */
.rectification-timeline__readout {
margin: 0;
display: flex;
@@ -2656,17 +2664,56 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-workspace__chat {
--rectification-timeline-height: 56px;
}
/* No `--assistant-content-inset` here (BUG-918). The bar is not in the
message list, so on a phone the 44px avatar gutter buys no alignment the
reader can see; it cost 88px of the 375px the readout has to fit into and
the row got clipped instead. The page margin is the honest edge on a
narrow viewport, and the axis reads wider for it. */
.rectification-timeline {
padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
padding-inline: var(--space-4);
}
/* Tighter column rhythm buys another 12px across three gaps; the row is a
list of short figures, not prose, so space-2 still separates them. */
.rectification-timeline__readout {
column-gap: var(--space-2);
}
/* `已对照 N 件` is the one item DESIGN.md §10 never wanted on the bar — it
belongs to the delivery card, and it is a different quantity from
`已答 N 题` (events matched, not questions answered). It is the fifth item
in a row budgeted for four, so on a phone it leaves the visual row. It
stays in the DOM and in the axis `aria-label`, so assistive tech still
reads it, and the range plus the working minute — the two figures the
product requires to be visible at all times — never shorten. */
.rectification-timeline__dated {
display: none;
}
}
/* `--rectification-jump-clearance` is the band the floating 跳到最新 chip
occupies above the composer: its 44px touch target plus the `space-3` the
overlay holds under itself. The transcript reserves that band **plus another
`space-3`** (BUG-919). Reserving exactly the band left the last tappable
option flush against the chip, so a choice card's final row read as covered
and its centre — the only part of the row the chip's `pointer-events: auto`
button actually intercepts — was hard to hit.
With `.conversation`'s own `space-4` the tail now keeps 84px of clear space:
56px of chip band and 28px of air. Eliminating overlap in *every* scroll
position would need `conversationAnchorThreshold` (96px) on top of the band,
i.e. 152px of trailing void under the last message at rest — a whole option
row of dead space on a 667px phone, and the chip only shows once the reader
has scrolled up past that threshold anyway. The chip's own placement and
visibility rule are unchanged (BUG-478 owns them). */
.rectification-workspace__chat .conversation {
--rectification-jump-clearance: calc(44px + var(--space-3));
padding-bottom: var(--space-4);
/* Anything scrolled into view lands above the chip band too. This is inert
for `use-conversation-scroll-anchor`, which assigns `scrollTop` directly
rather than calling a scroll-into-view API. */
scroll-padding-block-end: var(--rectification-jump-clearance);
}
.rectification-workspace__chat .message-list {
width: 100%;
padding-bottom: var(--rectification-jump-clearance);
padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3));
}
.conversation.is-rectification .message-actions {
margin-inline-start: var(--assistant-content-inset);
@@ -1489,20 +1489,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionMissing: currentQuestion === null,
offeringCards: showSelectionCards,
});
// BUG-917: the same judgement the message entry makes, read off the latest
// settled assistant message: a tap question nobody can answer any more.
const unansweredDeadChoiceOnMessages = Boolean(
latestSettledAssistant?.question
&& (latestSettledAssistant.question.kind === "choice"
|| latestSettledAssistant.question.kind === "reverse_verify")
&& !latestSettledAssistant.question.answer_option
&& (latestSettledAssistant.question.status === "active"
|| latestSettledAssistant.question.status === "superseded")
&& !liveQuestionOnMessages,
);
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
unansweredDeadChoice: unansweredDeadChoiceOnMessages,
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable" || deadChoice,
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice,
@@ -155,11 +155,9 @@ function RectificationMessageEntryView({
)
),
);
// A superseded focus the reader never answered has no answer to show either:
// its options would render greyed and unclickable (BUG-917).
const unansweredDeadChoice = Boolean(
question
&& (!questionIsAnswered(question) || (question.status === "superseded" && !question.answer_option))
&& !questionIsAnswered(question)
&& !liveQuestion
&& (question.kind === "choice" || question.kind === "reverse_verify")
);
@@ -24,6 +24,13 @@
* must never change — see `.rectification-timeline` in globals.css. The readout
* stays one line for that reason: it grew wider, never taller. An unready case
* renders the same-height skeleton rather than nothing.
*
* One line has a width budget, and on a 375px phone it is spent by the fourth
* item (BUG-918). Every span below is always rendered; globals.css decides how
* many of them a narrow viewport shows, and it hides `__dated` there. The axis
* `aria-label` below is assembled from all five labels regardless, so nothing a
* screen reader gets depends on the viewport. Adding a sixth item means
* re-measuring that budget, not appending a span.
*/
"use client";
@@ -46,6 +53,8 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie
{view.answeredLabel ? (
<span className="rectification-timeline__answered">{view.answeredLabel}</span>
) : null}
{/* Hidden below 768px by globals.css, never omitted here: the label is
part of the accessible axis description either way. */}
{view.datedEventLabel ? (
<span className="rectification-timeline__dated">{view.datedEventLabel}</span>
) : null}
@@ -338,14 +338,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
// Date reliability (above) runs first. Year-stage typed collect with a
// yearless short reply does not enter the model (BUG-910). BUG-916: this host
// answer runs after the session check, so a message from another session is
// rejected instead of written as a deterministic turn; the phase reuses the
// existing `answer.host_fallback` allowlist entry.
// yearless short reply does not enter the model (BUG-910).
const yearReask = shouldHostReaskYearEntry(dossier.conversationSummary.activeFocus, message);
if (yearReask && message) {
const persisted = await persistV9DeterministicTurn(accounting, userId, caseId, {
@@ -360,12 +354,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
turnStatus: "completed",
skillLoaded: true,
answerText: yearReask,
phases: ["answer.host_fallback"],
phases: ["answer.host_year_entry"],
toolsUsed: [],
errorCode: null,
previousFocusId,
};
}
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
const boundIdentity = await loadV9CaseSkillIdentity(accounting, userId, caseId);
if ((options.skillName && options.skillName !== boundIdentity.name)
|| (options.skillVersion && options.skillVersion !== boundIdentity.version)
@@ -37,7 +37,7 @@ import {
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { liveDistinguishProbe, withOwnedDistinguishProbes } from "./varga-distinguish-probe";
import { liveDistinguishProbe } from "./varga-distinguish-probe";
import { isSameCandidateSplit } from "../core/candidate-contrast-packet.ts";
import { datedEventCount } from "./divergence-panel.ts";
import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
@@ -566,54 +566,6 @@ function dossierWithSkippedTargetedCollect<T extends {
};
}
/**
* Whether an open distinguish focus has expired (BUG-559 split-hash change).
*
* `inference` must already carry the probes this layer owns (BUG-915): a focus
* whose probe was simply never rebuilt is fresh, not stale, and superseding it
* on every idle persist is what left the reader with a dead card.
*/
export function persistedDistinguishFocusStale(
inference: InferenceState | null,
focus: Readonly<{
intent?: string;
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
}> | null | undefined,
): boolean {
if (focus?.intent !== "distinguish_candidates") return false;
const schema = focus.expectedAnswerSchema
&& typeof focus.expectedAnswerSchema === "object"
&& !Array.isArray(focus.expectedAnswerSchema)
? focus.expectedAnswerSchema as Record<string, unknown>
: null;
if (!schema) return false;
const live = liveDistinguishProbe(inference, schema);
if (!live) return true;
const split = typeof schema.candidate_split_hash === "string" ? schema.candidate_split_hash : "";
return Boolean(
split && !isSameCandidateSplit(split, live.candidate_split_hash, live.semantic_key),
);
}
/**
* The state every persisted-answer path scores against (BUG-915).
*
* The schema on an open focus may name a probe this layer owns, which the
* engine's `inference_state` never carries: it is rebuilt from the same
* receipt. Without this the tap and the typed answer both fail `stale_probe`.
*/
export function inferenceForPersistedAnswer(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): InferenceState | null {
return withOwnedDistinguishProbes(
withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt ?? null),
refinementFromDecisionReceipt(receipt ?? null).nakshatra_boundary,
),
receipt ?? null,
);
}
export async function applyRectificationChoice(
accounting: AccountingClient,
command: ApplyChoiceCommand,
@@ -636,8 +588,10 @@ export async function applyRectificationChoice(
const questionId = focus.questionId;
const scoring = schema.scoring !== false && !questionId.endsWith(":holdout");
const receipt = dossier.latestResult?.decisionReceipt ?? null;
// BUG-915 injection point 2/5: answering a tap.
const previous = inferenceForPersistedAnswer(receipt);
const previous = withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
const answerClass = optionId === "stop" || optionId === "skip_probe"
? null
: outcomeIdForOption(optionId, schema);
@@ -1650,13 +1604,31 @@ export async function persistNextInterviewIfIdle(input: {
};
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
// BUG-915 injection point 4/5: idle supersede. Without the rebuild a fresh
// focus is superseded on every idle persist.
const inferenceForStale = withOwnedDistinguishProbes(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
dossier.latestResult?.decisionReceipt ?? null,
const inferenceForStale = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const distinguishSchema = staleFocus?.expectedAnswerSchema
&& typeof staleFocus.expectedAnswerSchema === "object"
&& !Array.isArray(staleFocus.expectedAnswerSchema)
? staleFocus.expectedAnswerSchema as Record<string, unknown>
: null;
const liveDistinguish = liveDistinguishProbe(inferenceForStale, distinguishSchema);
const distinguishSplit = typeof distinguishSchema?.candidate_split_hash === "string"
? distinguishSchema.candidate_split_hash
: "";
const staleDistinguish = Boolean(
staleFocus?.intent === "distinguish_candidates"
&& distinguishSchema
&& (
!liveDistinguish
|| (
distinguishSplit
&& !isSameCandidateSplit(
distinguishSplit,
liveDistinguish.candidate_split_hash,
liveDistinguish.semantic_key,
)
)
),
);
const staleDistinguish = persistedDistinguishFocusStale(inferenceForStale, staleFocus);
if (
isPersistedFocusId(staleFocusId)
&& (
@@ -55,7 +55,6 @@ import {
trainingScoreableGate,
} from "./evidence-model";
import { refinementFromDecisionReceipt, EVENT_PROBE_DOMAINS, type DiscriminatingEventProbe } from "./refinement-packet";
import { OWNED_DISTINGUISH_PROBE_SOURCE } from "./varga-distinguish-probe";
import { windowScanFromDecisionReceipt } from "./varga-observations";
import type { DroppedProbe } from "./probe-question-contract.ts";
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
@@ -228,8 +227,6 @@ export function contrastPacketFromLatestResult(
if (answered.has(probe.id) || probe.information_gain <= 0) return [];
if (probe.source === "known_event_quality" && !probe.target_evidence_id) return [];
if (probe.source === "nakshatra_boundary") return [];
// Rebuilt varga style probes answer old focuses only (BUG-915 / decision b).
if (probe.source === OWNED_DISTINGUISH_PROBE_SOURCE) return [];
return [{
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
@@ -386,7 +383,6 @@ export function rectificationFollowupCatalog(
const answeredIds = new Set((inference?.answered_probes ?? []).map((item) => item.probe_id));
const fromInference = (inference?.probes ?? []).flatMap((probe) => {
if (answeredIds.has(probe.id)) return [];
if (probe.source === OWNED_DISTINGUISH_PROBE_SOURCE) return [];
const mapped = eventProbeFromInference(probe);
return mapped ? [mapped] : [];
});
@@ -419,11 +415,7 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket
candidateSetVersion: state.candidate_set_id,
calculationResultId: null,
engineProbes: state.probes
.filter((item) => (
!answered.has(item.id)
&& item.source !== "nakshatra_boundary"
&& item.source !== OWNED_DISTINGUISH_PROBE_SOURCE
))
.filter((item) => !answered.has(item.id) && item.source !== "nakshatra_boundary")
.map((item) => ({
semantic_key: item.semantic_key,
candidate_split_hash: item.candidate_split_hash,
@@ -20,7 +20,6 @@ import {
internalObservationsFromWindowScan,
windowScanFromDecisionReceipt,
} from "./varga-observations";
import { withOwnedDistinguishProbes } from "./varga-distinguish-probe";
import type { RectificationChoiceCard } from "./choice-card";
import { followupCaseArgs } from "./block-scan.ts";
@@ -77,12 +76,7 @@ export function choiceCardFromCaseDossier(dossier: {
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
);
// BUG-915 injection point 3/5: the GET card projection matches the persisted
// schema against this state.
const inference = withOwnedDistinguishProbes(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
dossier.latestResult?.decisionReceipt ?? null,
);
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
@@ -173,7 +173,7 @@ import type {
} from "./refinement-packet";
import { EVENT_PROBE_DOMAINS } from "./refinement-packet";
import type { InternalVargaObservation, WindowScan } from "./varga-observations";
import { liveDistinguishProbe as liveInferenceDistinguishProbe } from "./varga-distinguish-probe";
import { attachVargaDistinguishIdentity, liveDistinguishProbe as liveInferenceDistinguishProbe } from "./varga-distinguish-probe";
import type { InferenceState } from "../core/types.ts";
import { probeBelowAdultFloor } from "./adult-floor.ts";
import {
@@ -306,20 +306,6 @@ function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain:
return evidence.some((item) => item.status === "confirmed" && sameCollectDomain(item.domain, domain));
}
/**
* Layer followups whose only discriminator used to be a varga style question.
* Product decision 2026-09-17 (option b) keeps them dated-probe backed.
*/
const VARGA_STYLE_LAYER_METHODS: Readonly<Record<string, true>> = {
d9_relationship: true,
d10_career: true,
d4_home: true,
d5_education: true,
relatives: true,
d2_finance: true,
d30_health: true,
};
export type AnsweredProbeCoverageRow = Readonly<{
semantic_key: string;
probe_id?: string;
@@ -2228,67 +2214,18 @@ export function buildMethodFollowupPlan(input: {
guidedWindows?: readonly GuidedCollectWindow[];
tieBreakRequested?: boolean;
}): MethodFollowupPlan {
const isLayerStyleDistinguish = (
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
): boolean => Boolean(
item.intent === "distinguish_candidates"
&& (item.source === "varga_observation" || item.source === "precision_stage")
&& !item.semantic_key
&& item.domain
&& item.method_id in VARGA_STYLE_LAYER_METHODS,
);
/**
* The dated event probe a layer followup may ask with. Anything already asked
* or answered stays excluded (BUG-559), and style / event-quality probes are
* never dated discriminators.
*/
const datedProbeForLayerFollowup = (
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
): DiscriminatingEventProbe | null => {
if (!isLayerStyleDistinguish(item)) return null;
const asked = new Set(input.askedProbeKeys ?? []);
const answered = new Set((input.answeredProbes ?? []).map((row) => row.semantic_key));
return (input.eventProbes ?? []).find((probe) => (
probe.domain === item.domain
&& probe.year > 0
&& probe.choice_kind !== "varga_style"
&& probe.choice_kind !== "event_quality"
&& !asked.has(probe.semantic_key ?? "")
&& !answered.has(probe.semantic_key ?? "")
)) ?? null;
};
const makeFollowup = (
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
scoring = true,
forceChoice?: boolean,
): MethodFollowup => {
// Product decision 2026-09-17 (option b): a varga style question is not a
// full-scoring discriminator. A layer followup is asked only when the
// engine still offers a dated event probe in that domain; otherwise the
// plan moves to the next line (BUG-915).
const datedProbe = datedProbeForLayerFollowup(item);
// A probe with no `semantic_key` cannot be stamped, so the followup stays
// keyless and the focus fails persist (BUG-674) rather than scoring the
// wrong probe (BUG-912).
const distinguished = datedProbe?.semantic_key
? {
...item,
semantic_key: datedProbe.semantic_key,
candidate_split_hash: datedProbe.candidate_split_hash,
candidate_ids: datedProbe.candidate_ids ?? item.candidate_ids,
expected_outcomes: datedProbe.expected_outcomes ?? item.expected_outcomes,
probe_year: datedProbe.year,
year_label: datedProbe.year_label,
...(datedProbe.month ? { probe_month: datedProbe.month } : {}),
choice_kind: "existence" as const,
information_gain: datedProbe.information_gain ?? item.information_gain,
probe_id: datedProbe.semantic_key,
}
: item;
const distinguished = attachVargaDistinguishIdentity(item, {
windowScan: input.windowScan ?? null,
candidates: input.candidateRanges?.map((row) => ({ id: row.id || row.time, time: row.time }))
?? (input.topCandidateTimes ?? []).map((time) => ({ id: time, time })),
});
const base = { ...distinguished, must_not_label: false as const };
const unbackedLayerCard = !datedProbe && isLayerStyleDistinguish(item);
const attach = forceChoice
?? (unbackedLayerCard ? false : shouldAttachChoiceFrame(base, input.evidence));
const attach = forceChoice ?? shouldAttachChoiceFrame(base, input.evidence);
const keyed = Boolean(base.semantic_key) && [
...(input.eventProbes ?? []),
...(input.eventClarificationProbes ?? []),
@@ -10,7 +10,7 @@ import {
previousInferenceFromReceipt,
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { withOwnedDistinguishProbes } from "./varga-distinguish-probe";
import { withFollowupOwnedProbe } from "./varga-distinguish-probe";
import {
spokenFollowupForUser,
spokenCollectFallbackFollowup,
@@ -121,14 +121,9 @@ export function expectedAnswerSchemaFor(
...(followup.widen_windows ? { widen_windows: followup.widen_windows } : {}),
};
const receipt = decisionReceipt ?? null;
// BUG-915 injection point 1/5: stamping. Probes this layer owns live only in
// the receipt's window scan, so rebuild them before matching the schema.
const state = withOwnedDistinguishProbes(
withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
),
receipt,
const state = withNakshatraBoundaryProbe(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
const verifyOnly = followup.intent === "reverse_verify" || followup.intent === "out_of_sample_check";
const targetedCollect = Boolean(
@@ -138,7 +133,7 @@ export function expectedAnswerSchemaFor(
if (!targetedCollect && decisionReceipt?.inference_state !== undefined && !state) return null;
const stamped = stampChoiceSchemaWithProbe(
schema,
verifyOnly || targetedCollect ? null : state,
verifyOnly || targetedCollect ? null : withFollowupOwnedProbe(state, followup),
questionId,
{
semantic_key: followup.semantic_key,
@@ -2,24 +2,12 @@
* D9/D10 (and sibling varga) distinguish followups must own their probe.
* Borrowing the highest-gain contrast probe (BUG-375 / BUG-912) scores the
* wrong chart and leaves GET without a card.
*
* A probe this layer builds is not in the engine's `inference_state`, so it
* must be rebuilt deterministically from the decision receipt at every state
* read (BUG-915), the way `withNakshatraBoundaryProbe` does. A probe that
* exists only in the memory copy that stamped the schema is unanswerable:
* the answer path, GET projection and idle supersede check all read the
* persisted receipt.
*
* Product decision 2026-09-17 (option b): varga style questions are not
* full-scoring `distinguish_candidates` probes, so the plan no longer asks
* one. The rebuild stays for focuses persisted before that decision and for
* option (a) if the product ever reverses it.
*/
import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts";
import { signFromTransitions, type TransitionSignLookup } from "../core/sign-from-transitions.ts";
import { signFromTransitions } from "../core/sign-from-transitions.ts";
import { completeStyleOptions } from "./probe-question-contract.ts";
import { d9StyleLabel, d10StyleLabel, signKey } from "./varga-type-tables.ts";
import { windowScanFromDecisionReceipt } from "./varga-observations.ts";
import type { WindowScan } from "./varga-observations.ts";
const METHOD_LAYERS: Readonly<Record<string, readonly string[]>> = {
d9_relationship: ["d9"],
@@ -95,7 +83,7 @@ function choiceKindFor(layer: string): "varga_style" | "existence" {
function groupsForLayer(
candidates: readonly VargaDistinguishCandidate[],
transitions: readonly TransitionSignLookup[],
transitions: WindowScan["transitions"],
layer: string,
): { sign: string; ids: string[] }[] {
const bySign = new Map<string, string[]>();
@@ -118,7 +106,7 @@ function groupsForLayer(
export function buildVargaDistinguishFields(input: {
methodId: string;
askTheme: string;
transitions: readonly TransitionSignLookup[] | undefined;
transitions: WindowScan["transitions"] | undefined;
candidates: readonly VargaDistinguishCandidate[];
}): VargaDistinguishFields | null {
const layers = METHOD_LAYERS[input.methodId];
@@ -167,6 +155,27 @@ export function buildVargaDistinguishFields(input: {
};
}
export function attachVargaDistinguishIdentity<T>(
item: T,
input: {
windowScan?: WindowScan | null;
candidates?: readonly VargaDistinguishCandidate[];
},
): T {
const row = item as T & DistinguishFollowup;
if (row.semantic_key) return item;
if (row.intent !== "distinguish_candidates") return item;
if (row.source !== "varga_observation" && row.source !== "precision_stage") return item;
const fields = buildVargaDistinguishFields({
methodId: row.method_id,
askTheme: row.ask_theme,
transitions: input.windowScan?.transitions,
candidates: input.candidates ?? [],
});
if (!fields) return item;
return { ...item, ...fields };
}
export function conflictProbeFromFollowup(followup: DistinguishFollowup): ConflictProbe | null {
const semanticKey = followup.semantic_key?.trim() ?? "";
const outcomes = (followup.expected_outcomes ?? []).flatMap((row) => {
@@ -198,69 +207,23 @@ export function conflictProbeFromFollowup(followup: DistinguishFollowup): Confli
};
}
/**
* Every varga style question this layer can own, keyed exactly as the plan
* names it (`<method_id>:<ask_theme>`). Kept as data so the rebuild does not
* depend on which followup happens to be asking.
*/
const OWNED_DISTINGUISH_QUESTIONS = [
{ methodId: "d9_relationship", askTheme: "relationship_style", domain: "relationship" },
{ methodId: "d10_career", askTheme: "career_style", domain: "career" },
{ methodId: "d4_home", askTheme: "home_change", domain: "relocation" },
{ methodId: "d5_education", askTheme: "education_style", domain: "education" },
{ methodId: "relatives", askTheme: "family_event", domain: "family" },
{ methodId: "d2_finance", askTheme: "finance_change", domain: "finance" },
{ methodId: "d30_health", askTheme: "health_pressure", domain: "health_pressure" },
] as const;
/**
* Rebuilt probes carry their own source so the decision layer can keep them out
* of the contrast packet and the event-probe pool: they exist only so a focus
* persisted before the 2026-09-17 (b) decision stays answerable, never as a
* question to ask.
*/
export const OWNED_DISTINGUISH_PROBE_SOURCE = "owned_varga_style";
/**
* Rebuild every probe this layer owns from the persisted receipt (BUG-915).
*
* Deterministic: the window-scan transitions plus the candidate minutes in the
* state decide the groups, so the answer path, the GET projection and the idle
* supersede check all see the same probe the schema was stamped with. Nothing
* here depends on the followup that is currently being asked.
*/
export function withOwnedDistinguishProbes(
export function withFollowupOwnedProbe(
state: InferenceState | null,
receipt: Readonly<Record<string, unknown>> | null | undefined,
followup: DistinguishFollowup | null | undefined,
): InferenceState | null {
if (!state) return state;
const transitions: readonly TransitionSignLookup[] = windowScanFromDecisionReceipt(receipt)?.transitions
?? state.transitions
?? [];
if (transitions.length === 0) return state;
const candidates = state.candidates.map((item) => ({ id: item.id, time: item.time }));
let probes = state.probes;
for (const row of OWNED_DISTINGUISH_QUESTIONS) {
const fields = buildVargaDistinguishFields({
methodId: row.methodId,
askTheme: row.askTheme,
transitions,
candidates,
});
if (!fields) continue;
const probe = conflictProbeFromFollowup({
method_id: row.methodId,
ask_theme: row.askTheme,
domain: row.domain,
intent: "distinguish_candidates",
source: OWNED_DISTINGUISH_PROBE_SOURCE,
...fields,
});
if (!probe) continue;
if (probes.some((item) => item.id === probe.id || item.semantic_key === probe.semantic_key)) continue;
probes = [...probes, probe];
if (!state || !followup) return state;
// Only inject probes this layer built. Catalog probes that are not yet in
// inference_state must still fail persist (BUG-912 must not undo T0 of
// rectification-probe-pool-exhausted-20260911).
const ownedKey = `${followup.method_id}:${followup.ask_theme}`;
if (followup.semantic_key !== ownedKey) return state;
if (followup.source !== "varga_observation" && followup.source !== "precision_stage") return state;
const probe = conflictProbeFromFollowup(followup);
if (!probe) return state;
if (state.probes.some((item) => item.id === probe.id || item.semantic_key === probe.semantic_key)) {
return state;
}
return probes === state.probes ? state : { ...state, probes };
return { ...state, probes: [...state.probes, probe] };
}
export function liveDistinguishProbe(
@@ -302,12 +302,6 @@ export type RectificationQuestionGapInput = Readonly<{
stopReason?: string | null;
/** Choice/targeted stem that cannot render a card. Delivery still takes this over repair. */
deadChoice?: boolean;
/**
* The latest assistant message still carries an unanswered tap question that
* cannot be answered (no live card, or its focus was superseded). A dead card
* and the collect-wait placeholder must never share a screen (BUG-917).
*/
unansweredDeadChoice?: boolean;
questionId?: string | null;
hasChoiceCard?: boolean;
busy: boolean;
@@ -335,9 +329,6 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible) return "idle";
if (interviewDeliveredGap(input)) return "delivered";
// BUG-917: an unanswerable tap question on the latest message is a repair
// exit, never 「再说一件带年月的事」.
if (input.unansweredDeadChoice) return "unavailable";
if (input.collectWaiting) return "collect_waiting";
if (input.questionPersisted) return "persisted_question";
if (input.offerAwaitingReader) return "idle";
@@ -91,7 +91,6 @@ import {
compactInferenceProjection,
previousInferenceFromReceipt,
} from "@/lib/rectification-agentic/v9/inference-adapter";
import { inferenceForPersistedAnswer } from "@/lib/rectification-agentic/v9/answer-choice";
import {
COLLECT_FOCUS_SCHEMA_KEY,
collectFocusSchema,
@@ -1411,11 +1410,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
// BUG-915 injection point 5/5: a typed answer to a persisted
// choice reads the same state the tap path reads.
const previous = inferenceForPersistedAnswer(
dossier.latestResult?.decisionReceipt ?? null,
);
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
if (focus && focus.id === input.focusId && previous) {
const applied = applyChoiceWithoutEvidence(previous, {
choiceKey: input.choiceKey,
@@ -1,39 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
stampChoiceSchemaWithProbe,
} from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import {
buildMethodFollowupPlan,
projectRectificationChoiceCard,
yearlessPersonalityCanAsk,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import { rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
inferenceForPersistedAnswer,
persistedDistinguishFocusStale,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { expectedAnswerSchemaFor } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { parseWindowScan } from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import {
buildVargaDistinguishFields,
withOwnedDistinguishProbes,
attachVargaDistinguishIdentity,
withFollowupOwnedProbe,
} from "../src/lib/rectification-agentic/v9/varga-distinguish-probe.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import { FOCUS_ID } from "./rectification-v9-test-support.ts";
const OWNED_KEY = "d9_relationship:relationship_style";
const QUESTION_ID = "d9_relationship:relationship_style:score";
const DATED = [
{
status: "confirmed",
@@ -84,8 +72,7 @@ const D24 = {
choice_kind: "event_quality" as const,
};
/** Engine window scan as it arrives on the decision receipt. */
const WINDOW_SCAN_RECEIPT = {
const WINDOW = parseWindowScan({
scanned: true,
d9_lagna_count: 2,
d10_lagna_count: 1,
@@ -97,18 +84,9 @@ const WINDOW_SCAN_RECEIPT = {
{ layer: "d9", at: "04:53", from_sign: "天蝎座", to_sign: "天秤座" },
{ layer: "d10", at: "04:40", from_sign: "巨蟹座", to_sign: "巨蟹座" },
],
};
});
const WINDOW = parseWindowScan(WINDOW_SCAN_RECEIPT)!;
const CANDIDATES = [
{ id: "04:48", time: "04:48" },
{ id: "04:53", time: "04:53" },
{ id: "04:59", time: "04:59" },
];
/** The persisted state: engine probes only, exactly as the receipt stores it. */
function persistedState() {
function state() {
return buildInferenceState({
range_start: "04:48",
range_end: "05:07",
@@ -127,24 +105,8 @@ function persistedState() {
});
}
function receipt() {
return { inference_state: persistedState(), window_scan: WINDOW_SCAN_RECEIPT };
}
/**
* A D9 style focus as it was persisted before the 2026-09-17 (b) decision:
* its identity is the layer's own key, and its probe lives nowhere but the
* receipt's window scan.
*/
function legacyD9Followup(): MethodFollowup {
const fields = buildVargaDistinguishFields({
methodId: "d9_relationship",
askTheme: "relationship_style",
transitions: WINDOW.transitions,
candidates: CANDIDATES,
});
assert.ok(fields);
const followup = {
function d9Followup() {
const attached = attachVargaDistinguishIdentity({
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
@@ -154,11 +116,17 @@ function legacyD9Followup(): MethodFollowup {
source: "varga_observation",
must_not_label: false,
choice_frame: null,
...fields,
} as MethodFollowup;
const frame = buildChoiceFrame(followup, {
}, {
windowScan: WINDOW,
candidates: [
{ id: "04:48", time: "04:48" },
{ id: "04:53", time: "04:53" },
{ id: "04:59", time: "04:59" },
],
}) as MethodFollowup;
const frame = buildChoiceFrame(attached, {
evidence: DATED,
probes: [{
probes: attached.style_options ? [{
year: 0,
year_label: "当前这几个候选",
domain: "relationship",
@@ -167,36 +135,23 @@ function legacyD9Followup(): MethodFollowup {
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: followup.user_prompt_hint,
user_meaning: attached.user_prompt_hint,
role: "distinguish",
phase: "candidate_discriminator",
information_gain: followup.information_gain,
semantic_key: followup.semantic_key,
candidate_split_hash: followup.candidate_split_hash,
candidate_ids: followup.candidate_ids,
expected_outcomes: followup.expected_outcomes,
information_gain: attached.information_gain,
semantic_key: attached.semantic_key,
candidate_split_hash: attached.candidate_split_hash,
candidate_ids: attached.candidate_ids,
expected_outcomes: attached.expected_outcomes,
choice_kind: "varga_style" as const,
style_options: followup.style_options,
}],
style_options: attached.style_options,
}] : [] as never,
});
assert.ok(frame);
return { ...followup, choice_frame: frame };
return { ...attached, choice_frame: frame } as MethodFollowup;
}
function persistedSchema(): Record<string, unknown> {
const followup = legacyD9Followup();
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
QUESTION_ID,
receipt(),
followup,
);
assert.ok(schema);
return schema;
}
test("stamping rebuilds the owned probe from the receipt instead of borrowing D24 (BUG-912/915)", () => {
const state = persistedState();
test("year-stage D9 followup does not borrow the D24 contrast probe (BUG-912)", () => {
const inference = state();
const borrowed = stampChoiceSchemaWithProbe({
choice: {
options: [
@@ -206,260 +161,89 @@ test("stamping rebuilds the owned probe from the receipt instead of borrowing D2
{ key: "D", answer_class: "unsure" },
],
},
}, state, QUESTION_ID);
}, inference, "d9_relationship:relationship_style:score");
assert.notEqual(borrowed.probe_id, D24.id);
// The persisted receipt itself never carries the owned probe.
const stored = previousInferenceFromReceipt(receipt());
assert.ok(stored);
assert.equal(stored.probes.some((probe) => probe.semantic_key === OWNED_KEY), false);
const rebuilt = withOwnedDistinguishProbes(stored, receipt());
assert.ok(rebuilt?.probes.some((probe) => probe.semantic_key === OWNED_KEY));
const schema = persistedSchema();
assert.equal(schema.semantic_key, OWNED_KEY);
assert.equal(schema.probe_id, `probe:${OWNED_KEY}`);
const followup = d9Followup();
assert.equal(followup.semantic_key, "d9_relationship:relationship_style");
assert.ok(followup.choice_frame);
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
followup.choice_frame!.question_id,
{ inference_state: inference, window_scan: WINDOW },
followup,
);
assert.ok(schema);
assert.equal(schema.semantic_key, "d9_relationship:relationship_style");
assert.notEqual(String(schema.probe_id ?? ""), D24.id);
});
assert.match(String(schema.probe_id ?? ""), /d9_relationship:relationship_style/);
test("the production answer path applies a persisted owned-probe choice (BUG-915)", () => {
const schema = persistedSchema();
const answerInput = {
const applied = applyChoiceWithoutEvidence(withFollowupOwnedProbe(inference, followup)!, {
choiceKey: "C",
schema,
questionId: QUESTION_ID,
questionId: "d9_relationship:relationship_style:score",
domain: "relationship",
classifiedFrom: "choice" as const,
};
// Without the rebuild the same schema is unanswerable: that was the P0.
const withoutRebuild = applyChoiceWithoutEvidence(previousInferenceFromReceipt(receipt())!, answerInput);
assert.equal(withoutRebuild.applied, false);
assert.equal(withoutRebuild.reason, "stale_probe");
const applied = applyChoiceWithoutEvidence(inferenceForPersistedAnswer(receipt())!, answerInput);
assert.equal(applied.reason, "applied");
classifiedFrom: "choice",
});
assert.equal(applied.applied, true);
assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, OWNED_KEY);
assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, "d9_relationship:relationship_style");
assert.ok(!applied.state.answered_probes.some((row) => row.probe_id === D24.id));
});
test("GET projects the persisted D9 card from the receipt alone (BUG-915)", () => {
const schema = persistedSchema();
const card = choiceCardFromCaseDossier({
evidence: DATED,
conversationSummary: {
activeFocus: {
id: FOCUS_ID,
questionId: QUESTION_ID,
intent: "distinguish_candidates",
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: schema,
},
declinedSkippedTopics: [],
},
latestResult: {
resultId: "result-1",
decisionReceipt: receipt(),
selectionAllowed: false,
confirmationAllowed: false,
candidates: [
{ time: "04:48", rank: 1, relativeSupport: 44 },
{ time: "04:53", rank: 2, relativeSupport: 35 },
{ time: "04:59", rank: 3, relativeSupport: 21 },
],
},
case: { acceptedTime: null, status: "active", stage: "minute" },
});
assert.ok(card, "GET must render the persisted focus");
assert.equal(card.question_id, QUESTION_ID);
assert.equal(card.options.length, 4);
});
test("idle persist does not supersede a focus whose probe is rebuilt (BUG-915)", () => {
const schema = persistedSchema();
const focus = { intent: "distinguish_candidates", expectedAnswerSchema: schema };
// The raw receipt state has no owned probe: that is what superseded the
// fresh focus on every idle persist.
assert.equal(persistedDistinguishFocusStale(previousInferenceFromReceipt(receipt()), focus), true);
assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(receipt()), focus), false);
// A changed candidate set still expires the focus (BUG-559 stays).
const movedReceipt = {
window_scan: WINDOW_SCAN_RECEIPT,
inference_state: buildInferenceState({
range_start: "04:48",
range_end: "05:07",
candidates: [
{ id: "04:48", time: "04:48", relative_support: 60 },
{ id: "04:59", time: "04:59", relative_support: 40 },
],
events: persistedState().events,
probes: [D24],
}),
};
assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(movedReceipt), focus), true);
});
test("every state read that can touch an owned probe rebuilds it (BUG-915 source contract)", () => {
const sites = [
"src/lib/rectification-agentic/v9/server-focus.ts",
"src/lib/rectification-agentic/v9/interview-state.ts",
];
for (const site of sites) {
const source = readFileSync(new URL(`../${site}`, import.meta.url), "utf8");
assert.match(source, /withOwnedDistinguishProbes\(/, site);
}
const answerChoice = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
test("GET projects the persisted distinguish copy when its probe is still live (BUG-912)", () => {
const inference = state();
const followup = d9Followup();
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
"d9_relationship:relationship_style:score",
{ inference_state: inference, window_scan: WINDOW },
followup,
);
// The tap answer and the idle supersede check both read the rebuilt state.
assert.match(answerChoice, /const previous = inferenceForPersistedAnswer\(receipt\);/);
assert.match(answerChoice, /const inferenceForStale = withOwnedDistinguishProbes\(/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
// The typed answer goes through the same helper as the tap.
assert.match(tools, /const previous = inferenceForPersistedAnswer\(/);
});
const REL_2020_PROBE = {
year: 2020,
year_label: "2020 年前后",
domain: "relationship" as const,
event_family: "开始认真关系、分手或结婚",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "2020 年感情线",
role: "distinguish" as const,
phase: "candidate_discriminator" as const,
information_gain: 0.5,
semantic_key: "relationship.2020",
candidate_split_hash: "set:relationship.2020",
candidate_ids: ["04:48", "04:53", "04:59"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:48"], conflicts: ["04:53", "04:59"] },
{ answer_class: "weak_yes" as const, supports: ["04:53"], conflicts: ["04:48", "04:59"] },
{ answer_class: "no" as const, supports: ["04:59"], conflicts: ["04:48", "04:53"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
choice_kind: "existence" as const,
};
/** Every dated collect line already closed, as in the 2026-09-17 real run. */
const CLOSED_COLLECT = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
"career",
"relationship",
].map((domain) => ({
question_id: `collect:targeted:${domain}`,
target_kind: `targeted:${domain}`,
domain,
status: "declined",
intent: "collect_method_evidence",
}));
function planWithD9Split(input: { askedProbeKeys: readonly string[] }) {
return buildMethodFollowupPlan({
evidence: DATED,
observations: [
{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" },
{ layer: "d10", candidates_differ: false, ask_theme: null },
const withOwned = buildInferenceState({
range_start: "04:48",
range_end: "05:07",
candidates: [
{ id: "04:48", time: "04:48", relative_support: 44 },
{ id: "04:53", time: "04:53", relative_support: 35 },
{ id: "04:59", time: "04:59", relative_support: 21 },
],
events: inference.events,
probes: [
D24,
{
id: String(schema?.probe_id),
semantic_key: String(schema?.semantic_key),
candidate_split_hash: String(schema?.candidate_split_hash),
domain: "relationship",
year: 0,
question: "D9",
candidate_ids: ["04:48", "04:59"],
expected_outcomes: D24.expected_outcomes,
information_gain: 0.4,
source: "varga_observation",
choice_kind: "varga_style" as const,
style_options: followup.style_options as never,
},
],
windowScan: WINDOW,
inferenceState: persistedState(),
sessionOutcome: "discriminate_candidates",
precisionStage: "d9_refine",
eventProbes: [REL_2020_PROBE],
askedProbeKeys: input.askedProbeKeys,
closedCollectFocuses: CLOSED_COLLECT,
topCandidateTimes: ["04:48", "04:53", "04:59"],
candidateRanges: CANDIDATES.map((row) => ({ id: row.id, time: row.time })),
});
}
test("a varga style question is never a scoring discriminator (2026-09-17 decision b)", () => {
// The engine's relationship probe is already asked, so nothing dates this
// line: before the decision this is where 「你更接近哪一种相处方式」 came from.
const plan = planWithD9Split({ askedProbeKeys: ["relationship.2020"] });
const next = plan.next_followup;
assert.notEqual(next?.semantic_key, OWNED_KEY);
assert.notEqual(next?.choice_kind, "varga_style");
assert.doesNotMatch(next?.choice_frame?.prompt ?? "", /更接近哪一种/);
assert.equal(
next?.intent === "distinguish_candidates"
&& (next.source === "varga_observation" || next.source === "precision_stage"),
false,
JSON.stringify({ intent: next?.intent, source: next?.source, key: next?.semantic_key }),
);
assert.ok(
plan.dropped_probes.some((row) => (
row.semantic_key === "d9_relationship" && row.reason === "frameless_distinguish"
)),
JSON.stringify(plan.dropped_probes),
);
});
test("the D9 line is still asked when the engine dates it (2026-09-17 decision b)", () => {
const plan = planWithD9Split({ askedProbeKeys: [] });
const next = plan.next_followup;
assert.equal(next?.method_id, "d9_relationship");
assert.equal(next?.intent, "distinguish_candidates");
assert.equal(next?.semantic_key, REL_2020_PROBE.semantic_key);
assert.equal(next?.choice_kind, "existence");
assert.match(next?.choice_frame?.prompt ?? "", /2020/);
});
test("a rebuilt owned probe never becomes an askable discriminator (decision b)", () => {
// Answering an old style focus writes the rebuilt probes into the receipt.
const answered = withOwnedDistinguishProbes(previousInferenceFromReceipt(receipt()), receipt())!;
const owned = answered.probes.find((probe) => probe.semantic_key === OWNED_KEY);
assert.ok(owned);
const catalog = rectificationFollowupCatalog(
{ resultId: "result-1", decisionReceipt: { ...receipt(), inference_state: answered } },
DATED,
);
assert.equal(catalog.eventProbes.some((probe) => probe.semantic_key === OWNED_KEY), false);
assert.equal(
(catalog.contrastPacket?.probes ?? []).some((probe) => probe.semanticKey === OWNED_KEY),
false,
);
});
test("a dead tap card never shares the screen with the collect-wait placeholder (BUG-917)", () => {
const base = {
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
collectWaiting: true,
};
assert.equal(rectificationQuestionGapState(base), "collect_waiting");
assert.equal(
rectificationQuestionGapState({ ...base, unansweredDeadChoice: true }),
"unavailable",
);
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(chat, /unansweredDeadChoice: unansweredDeadChoiceOnMessages,/);
assert.match(chat, /latestSettledAssistant\.question\.status === "superseded"/);
const entry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
assert.match(entry, /question\.status === "superseded" && !question\.answer_option/);
const card = projectRectificationChoiceCard({
evidence: DATED,
activeFocus: {
id: FOCUS_ID,
questionId: "d9_relationship:relationship_style:score",
intent: "distinguish_candidates",
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: schema,
},
observations: [{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" }],
windowScan: WINDOW,
inferenceState: withOwned,
sessionOutcome: "collect_evidence",
caseRevision: 1,
});
assert.equal(card?.question_id, "d9_relationship:relationship_style:score");
assert.equal(card?.options.length, 4);
});
test("five dated events do not hold for yearless personality (BUG-913 closed_by_design)", () => {
@@ -471,7 +255,7 @@ test("nakshatra opposite poles are not both lifted as shared traits (BUG-914)",
const earlier = "希望两边都能说得过去";
const later = "必要时会直接选边";
const delivery = buildRangeDelivery({
inference: persistedState(),
inference: state(),
publicCandidates: [
{ candidateId: "a", time: "04:48" },
{ candidateId: "b", time: "04:53" },
@@ -0,0 +1,200 @@
/**
* 2026-09-17, iPhone on staging: the rectification timeline readout came through
* as `04:48–05:07 20 分钟 代表分钟 04:53 已…` (BUG-918), and the floating
* 跳到最新 chip sat on top of the last tappable option of a choice card
* (BUG-919).
*
* What this file can and cannot check, stated plainly: there is no jsdom or
* happy-dom in this suite, so `renderToStaticMarkup` is the only render path and
* `scrollWidth` / `clientWidth` do not exist to compare. Every geometric claim
* below is therefore **arithmetic over the rendered text**, using deliberately
* generous per-glyph widths (see `readoutWidthPx`), plus CSS declaration
* assertions of the kind `chart-page-view.test.tsx` uses. A real-device pass at
* 375 and 390 CSS px is still required and lives in
* `docs/testing/rectification-mobile-timeline-readout-20260917.md`.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { RectificationTimeline } from "../src/components/rectification-timeline.tsx";
import { buildRectificationTimeline } from "../src/lib/rectification-timeline-scale.ts";
Object.assign(globalThis, { React });
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
/** Comments carry prose that would otherwise satisfy declaration assertions. */
const declarations = styles.replace(/\/\*[\s\S]*?\*\//g, " ");
/** The `@media (max-width: 767px)` block that owns the timeline, and only it. */
function compactTimelineBlock(source: string): string {
const start = source.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {\n --rectification-timeline-height: 56px;");
assert.notEqual(start, -1, "compact timeline media block not found");
let depth = 0;
for (let at = start; at < source.length; at += 1) {
if (source[at] === "{") depth += 1;
else if (source[at] === "}") {
depth -= 1;
if (depth === 0) return source.slice(start, at + 1);
}
}
assert.fail("compact timeline media block is unbalanced");
}
function ruleBody(source: string, selector: string): string {
const at = source.indexOf(`${selector} {`);
assert.notEqual(at, -1, `missing rule: ${selector}`);
const end = source.indexOf("}", at);
assert.notEqual(end, -1, `unterminated rule: ${selector}`);
return source.slice(at + selector.length + 2, end);
}
/** The readout's five spans in document order, text only. */
function readoutItems(markup: string): string[] {
const readout = markup.match(/<p class="rectification-timeline__readout">([\s\S]*?)<\/p>/);
assert.ok(readout, "readout paragraph not rendered");
return [...readout[1].matchAll(/<span class="rectification-timeline__[a-z-]+">([^<]*)<\/span>/g)]
.map((match) => match[1]);
}
/**
* Upper bound on a readout item's rendered width at 13px, the size the bar is
* pinned to. Every figure here is rounded **up** from the system sans stacks the
* app ships (digits measure about 0.556em in -apple-system / Helvetica and the
* readout asks for tabular-nums, so 0.6em is slack, not a guess). An assertion
* that passes against these numbers passes against the real font.
*/
const GLYPH_EM: Readonly<Record<string, number>> = {
digit: 0.6,
colon: 0.33,
space: 0.28,
dash: 0.5,
cjk: 1,
other: 0.6,
};
function readoutWidthPx(text: string, fontSizePx = 13): number {
let em = 0;
for (const character of text) {
if (/\d/.test(character)) em += GLYPH_EM.digit;
else if (character === ":") em += GLYPH_EM.colon;
else if (character === " ") em += GLYPH_EM.space;
else if (character === "–" || character === "-") em += GLYPH_EM.dash;
else if (/[ -鿿豈-﫿]/.test(character)) em += GLYPH_EM.cjk;
else em += GLYPH_EM.other;
}
return em * fontSizePx;
}
/** The worst realistic readout: every optional item present, widest labels. */
const fullView = buildRectificationTimeline({
searchWindow: ["04:30", "05:30"],
credibleRange: ["04:48", "05:07"],
candidateTimes: ["04:48", "04:53", "05:07"],
stage: "minute",
workingTime: "04:53",
answeredProbeCount: 6,
datedEventCount: 3,
});
test("the timeline renders every readout figure the projection carries", () => {
assert.ok(fullView, "the fixture window has to produce a view");
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
assert.deepEqual(items, ["04:48–05:07", "20 分钟", "代表分钟 04:53", "已答 6 题", "已对照 3 件"]);
// BUG-918's fix is CSS, not omission: the compact viewport hides one item, it
// never drops it from the markup, so the accessible axis description below
// reads the same on every viewport.
const markup = renderToStaticMarkup(<RectificationTimeline view={fullView} />);
const axisLabel = markup.match(/aria-label="([^"]*)"/)?.[1] ?? "";
for (const item of items) {
assert.ok(axisLabel.includes(item), `axis label is missing ${item}`);
}
});
test("the four items a 375px phone shows fit the compact content box", () => {
assert.ok(fullView);
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
// `.rectification-timeline__dated` is display:none below 768px, so the visible
// row is the first four.
const visible = items.slice(0, 4);
const columnGapPx = 8; // --space-2, the compact column-gap
const required = visible.reduce((total, item) => total + readoutWidthPx(item), 0)
+ columnGapPx * (visible.length - 1);
// 375 CSS px (iPhone SE 2/3, 13 mini) minus the compact padding-inline,
// var(--space-4) on each side. The 44px avatar inset that used to be added
// here is what pushed the row over (BUG-918).
const available375 = 375 - 2 * 16;
assert.ok(
required <= available375,
`readout needs ${required.toFixed(0)}px, ${available375}px available at 375px`,
);
// 390 CSS px (iPhone 12–16 base) has to hold too.
assert.ok(required <= 390 - 2 * 16);
// And the shape of the old bug: with the inset and all five items it did not
// fit, which is why the fourth item arrived as 「已…」.
const oldRequired = items.reduce((total, item) => total + readoutWidthPx(item), 0) + 12 * (items.length - 1);
assert.ok(oldRequired > 375 - 2 * (16 + 44));
});
test("the compact timeline drops the avatar inset and the fifth figure", () => {
const compact = compactTimelineBlock(declarations);
// Page margin only — no --assistant-content-inset on a phone.
assert.match(ruleBody(compact, ".rectification-timeline"), /padding-inline: var\(--space-4\);/);
assert.doesNotMatch(ruleBody(compact, ".rectification-timeline"), /--assistant-content-inset/);
assert.match(ruleBody(compact, ".rectification-timeline__readout"), /column-gap: var\(--space-2\);/);
assert.match(ruleBody(compact, ".rectification-timeline__dated"), /display: none;/);
// The range and the working minute are the two figures the product requires
// to stay visible at every width, so neither may be hidden or shrunk here.
assert.doesNotMatch(compact, /\.rectification-timeline__(range|width|working)\b/);
// Still one line, still a fixed-height bar: the compact block must not undo
// either, or the bar would resize outside anything observing it.
assert.doesNotMatch(compact, /flex-wrap: wrap|white-space: normal/);
assert.match(compact, /--rectification-timeline-height: 56px;/);
});
test("the desktop readout keeps all five figures and the assistant-column inset", () => {
const desktop = ruleBody(declarations, ".rectification-timeline");
assert.match(desktop, /padding: 0 calc\(var\(--space-8\) \+ var\(--assistant-content-inset\)\);/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*white-space: nowrap;/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*flex-wrap: nowrap;/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*font-size: 13px;/);
});
test("the transcript reserves the jump-to-latest band plus air below it", () => {
// BUG-919: reserving exactly the chip band left the last option flush under
// the chip. The band is the chip's 44px target plus the space-3 it holds
// under itself; the list now reserves that plus one more space-3.
assert.match(
declarations,
/\.rectification-workspace__chat \.conversation \{[^}]*--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/,
);
assert.match(
declarations,
/\.rectification-workspace__chat \.message-list \{[^}]*padding-bottom: calc\(var\(--rectification-jump-clearance\) \+ var\(--space-3\)\);/,
);
// Anything scrolled into view lands above the band too.
assert.match(
declarations,
/\.rectification-workspace__chat \.conversation \{[^}]*scroll-padding-block-end: var\(--rectification-jump-clearance\);/,
);
});
test("the jump-to-latest overlay itself is untouched by the clearance change", () => {
// Its placement and visibility rule belong to BUG-478; this round only widened
// the transcript's reservation.
assert.match(declarations, /\.jump-to-latest \{[^}]*position: absolute;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*bottom: 100%;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*justify-content: center;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*padding-bottom: var\(--space-3\);/);
assert.match(declarations, /\.jump-to-latest \{[^}]*pointer-events: none;/);
assert.match(declarations, /\.jump-to-latest__button \{[^}]*min-height: 44px;/);
// No second scroll-follow and no reflowing layout for the chip.
const anchor = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
assert.doesNotMatch(anchor, /scroll-padding|rectification-jump-clearance/);
assert.match(anchor, /export const conversationAnchorThreshold = 96;/);
});
@@ -418,7 +418,12 @@ test("the chat grid has three rows and the timeline row is a fixed height", () =
test("the mobile bar stays within the 44px touch rhythm", () => {
const mobile = styles.slice(styles.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {"));
assert.match(mobile, /--rectification-timeline-height: 56px;/);
assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/);
// 原值: padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
// 新值: padding-inline: var(--space-4);
// 原因: BUG-918。真机 375px 下这 44px 头像沟槽让读数放不下,第四项被
// text-overflow 裁成「已…」。条不在消息列里,手机上对齐头像列没有
// 可见收益,页面边距才是诚实的边界。桌面那条规则不变(上一条测试)。
assert.match(mobile, /padding-inline: var\(--space-4\);/);
// 56px is the existing jump-clearance constant, not a new number.
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
});