fix(web): 账户弹窗移出 inert;校正快照失败不再静默(BUG-968/969)
账户弹窗与入门付费墙 portal 到 document.body,SidebarInset 的 inert 不再罩住弹窗。 GET 校正快照每个非 200 打 JSON warn;客户端失败显示「这一问还没读到」和重新读取。 下一题是可渲染选择题时,确定性回复正文带题干,挂卡后去重。
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-18 — 账户弹窗重新能点;校正读不到下一题时会说出来
|
||||
|
||||
侧栏打开个人资料、星盘资料、设置、点数或退出登录时,弹窗里的按钮和表单又能点了,确认退出也恢复了。生时校正如果没读到下一题,会写「这一问还没读到。」并给出「重新读取」,不再停在空白里装没事。Skill 版本不变。
|
||||
|
||||
## 2026-09-18 — 申报时段计算改为服务端先跑
|
||||
|
||||
只知道一段出生范围时,窗口比较在模型开口前就先算好。模型按这份结果写即可,不必再决定调不调计算工具。Skill 版本不变。
|
||||
|
||||
+9
-9
@@ -12752,32 +12752,32 @@
|
||||
|
||||
## BUG-968 | 账户弹窗打开后整个弹窗点不动,退出登录做不了
|
||||
|
||||
- 状态:investigating(根因已由代码确认,修复未做)
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-18
|
||||
- 最近更新:2026-09-18
|
||||
- 影响面:`src/app/(app)/layout.tsx` `AppShell`、`src/app/(app)/page.tsx` `AccountDialogOverlay` 渲染位置、`use-home-shell-registration.ts` `insetInert`;staging `877128ce`
|
||||
- 用户现象:点侧栏「个人资料 / 退出登录」等任一账户入口,弹窗出现但关闭按钮、表单、确认退出全部点不动;正文区同时点不动。刷新后依旧。
|
||||
- 触发条件:任何让 `modalOpen` 为真的弹窗(五个账户弹窗、onboarding 付费墙)。
|
||||
- 根因:`e4e73f56` 把 `SidebarInset` 搬进 `(app)/layout.tsx` 后,`inert={modalOpen}` 罩住了整个 `children`,而 `AccountDialogOverlay` 由 `page.tsx` 渲染、没有 portal,落在 inert 子树里。搬家前弹窗是 `SidebarInset` 的兄弟节点。
|
||||
- 修复:待做,见 `docs/tasks/TASK-account-dialog-inert-20260918.md` §3.1。
|
||||
- 验证:待做。临时脱困:按 Esc(`window` 级监听不受 inert 影响)。
|
||||
- 防复发:合同测试断言 `role="dialog"` 不在 inert 祖先内。
|
||||
- 修复:选 (a) `createPortal(..., document.body)`。`AccountDialogOverlay` 与 onboarding 付费墙都挂到 `document.body`;`SidebarInset` 的 `inert={modalOpen}` 和移动端抽屉 `isMobile && openMobile` 分支不动。选 portal 而不是把节点交给 layout,是为了不改 `registerShellControls` 协议、并保住现有 DOM 结构与样式。
|
||||
- 验证:`frontend/tests/account-dialog-inert-20260918.test.ts` 断言 overlay / paywall portal 到 `document.body`、layout 仍 inert `SidebarInset`、`role="dialog"` 渲染不含 inert;现有 overlay / sidebar / a11y 合同见定向测试。真机五弹窗点关闭/提交见 `docs/testing/account-dialog-inert-20260918.md`。
|
||||
- 防复发:合同测试断言弹窗 portal 到 `document.body`,且 `SidebarInset` 仍吃 `inert`。
|
||||
- 关联记录:BUG-744~746(侧栏统一)、BUG-926~929(外壳搬家)、BUG-965(跨部署标签页,另一问题)
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
- 修复版本:`1ace0e64`
|
||||
|
||||
## BUG-969 | 生时校正「换一件事问」之后无下文:快照拿不到,两端都不吭声
|
||||
|
||||
- 状态:investigating
|
||||
- 状态:mitigated(快照失败根因仍未由线上日志证实,不得写 resolved)
|
||||
- 首次发现:2026-09-18
|
||||
- 最近更新:2026-09-18
|
||||
- 影响面:`GET /api/rectification/cases/[caseId]`、`rectification-agentic-chat.tsx` `loadCaseSnapshot`、`rectification-surface-state.ts` `hydrateRectificationCase`、确定性回复的题干投递
|
||||
- 用户现象:口述两轮经历后说「想不到了」,回复「已记录。这题先不计分,换一件事问。」然后没有题卡;右侧盘面停在占位、时间轴无候选点、输入框占位是「再说一件带年月的事」。
|
||||
- 触发条件:真实 Case 上服务端已落库下一道财务探针(active,绑在该回复轮),GET 快照本地用真实数据行重算完整、客户端解析函数全部通过;客户端却仍是开场时的快照状态。
|
||||
- 根因:未定。已确认的是链路两端都静默:GET 路由所有异常 503 不写日志,客户端快照失败一律吞掉,边缘与应用层都无 GET 访问日志,PG 未开函数统计。事后无法证明请求是否到达服务端。
|
||||
- 修复:待做,见 `docs/tasks/TASK-account-dialog-inert-20260918.md` §3.2(服务端非 200 记日志、客户端失败显示状态、确定性回复正文带题干)。
|
||||
- 验证:待做。
|
||||
- 修复:① GET 每一个非 200(401/400/409/404/503,含 supabase 配置失败)`console.warn` 一条 JSON(`event=rectification_case_get_failed`、`case_id`、`status`、`code`、`reason=safeToolErrorCode`,不含用户资料)。② `loadCaseSnapshot` / `hydrateRectificationCase` / `refreshRectificationCase` 把非 2xx 或网络错误带回调用方;校正面显示「这一问还没读到。」+「重新读取」,不再装成采集等待。③ 下一题是可渲染选择题时,`persistApplied` 与 `persistCollectDenialTurn` 用 `composeCollectSpokenAssistantText` 把题干追加进正文并随流发出;卡片仍由 GET 挂上,`attachQuestionsToTurns` 的 `stripQuestionSentences` 去重。
|
||||
- 验证:`frontend/tests/account-dialog-inert-20260918.test.ts` 覆盖 GET warn、503 文案与按钮、ack+stem 流式及挂卡后题干只出现一次;hydrate 失败带 `failure.status`。真机清单见 `docs/testing/account-dialog-inert-20260918.md`。若真机仍复现,凭服务端 JSON warn 把本条转 confirmed 并补根因。
|
||||
- 防复发:确定性回复不得只靠 GET 挂卡投递题干;快照失败不得静默。
|
||||
- 关联记录:BUG-525(题干经 asked_turn_id 挂上)、BUG-930(回答钉顶:只见最后一轮是设计)、BUG-965
|
||||
- 复发自:待确认
|
||||
- 修复版本:待发布
|
||||
- 修复版本:`1ace0e64`
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# PROGRESS · 账户弹窗 inert + 校正快照不再静默(2026-09-18)
|
||||
|
||||
工作树:`.worktrees/account-dialog-inert-20260918`
|
||||
分支:`codex/account-dialog-inert-20260918`
|
||||
基线:`origin/staging` @ `e760b1d6`(任务书写 `1061514f`,开工时 staging 已快进到此)
|
||||
合入提交:`1ace0e64`
|
||||
任务书:`docs/tasks/TASK-account-dialog-inert-20260918.md`
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 3.1 BUG-968 弹窗移出 inert | 完成 | 选 (a) `createPortal(..., document.body)`。账户弹窗与 onboarding 付费墙都挂到 `document.body`。`SidebarInset` 的 `inert={modalOpen}` 保留,移动端抽屉分支不动。 |
|
||||
| 3.2.1 GET 非 200 记 warn | 完成 | `warnRectificationCaseGet`:401/400/409/404/503 及 supabase 配置失败都打 JSON(`event` / `case_id` / `status` / `code` / `reason`),不含用户资料。 |
|
||||
| 3.2.2 客户端失败不静默 | 完成 | `loadCaseSnapshot` 非 2xx / 网络错误写入 `snapshotFailure`;校正面「这一问还没读到。」+「重新读取」。hydrate 带 `failure.status`。`refreshRectificationCase` 失败走 composer notice。Abort 不当失败。 |
|
||||
| 3.2.3 确定性回复带题干 | 完成 | 下一题是可渲染选择题时,`persistApplied` 与 `persistCollectDenialTurn` 用 `composeCollectSpokenAssistantText` 追加题干并随流发出。口述采集仍只 stream ack(BUG-525)。`attachQuestionsToTurns` 去重。 |
|
||||
|
||||
## 方案选择(3.1)
|
||||
|
||||
选 **(a) createPortal 到 document.body**,不选 (b) 把节点交给 AppShell。
|
||||
|
||||
原因:现有 overlay 的 DOM、焦点圈禁(`overlayRef` + `keepFocusWithin`)、Esc 关闭都不用改;不必扩展 `registerShellControls`。SSR 走 `typeof document === "undefined"` 的原树,现有 `renderToString` 合同继续看见 dialog。
|
||||
|
||||
## 让步
|
||||
|
||||
无。3.1 与 3.2 的 1→2→3 都做完。BUG-969 快照失败的根因仍未由线上日志证实,状态写 **mitigated**,不得写 resolved。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | **0 error** / 120 warning(未顺手修 warning) |
|
||||
| 定向 | `account-dialog-inert-20260918` 4/4;overlay / surface-state / surface-contract / collect-stall / question-in-message / membership / sidebar-contract / a11y / home-shell-growth / answer-choice **全绿** |
|
||||
| 全量 `npm test` | 3480 条,3402 绿,**78 红**。失败清单是 Windows 无 Docker / skill symlink `EPERM` / Unix 部署脚本,与本机近期口径一致;新增 4 条全绿,被改过的 discriminator 断言已按三栏改完。Linux 门禁基线仍是 31 红。 |
|
||||
| `Home()` 冻结 | useState / useRef 未增长 |
|
||||
@@ -143,7 +143,7 @@
|
||||
| `TASK-window-consult-contract-20260918.md` §5 | `PROGRESS-window-consult-precompute-20260918.md` | BUG-957:申报时段计算改为服务端预跑并走同请求缓存。产品确认 BUG-954 已在 staging 验证,本轮单独做。不得 `toolChoice: "required"`,本命不预跑 | 待验收 | `53d78137` |
|
||||
| `TASK-chat-markdown-list-20260918.md` | `PROGRESS-chat-markdown-list-20260918.md` | 真机排版反馈:聊天正文列表**没有项目符号**(Tailwind v4 preflight 清了 `list-style`,`.markdown-list` 没恢复,BUG-962);`promoteDefinitionLists` 把四标题口语体的散文误判成并列项,三段正文被改写成列表(BUG-963,判据太松,收紧而非删除——BUG-356 的场景要留);思考条与正文之间实测 56px=报告 grid gap 24 + 首标题 margin-top 32,后者是被特指度压掉的「首元素清零」规则(BUG-964)。基线 `ea0280c1` | 待验收 | `dccedf37` |
|
||||
| `TASK-secondary-page-entry-20260918.md` | `PROGRESS-secondary-page-entry-20260918.md` | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 比对自愈。基线 `41902067` | 待验收 | `9ccb65b7` |
|
||||
| `TASK-account-dialog-inert-20260918.md` | — | 真机:账户弹窗打开后整个弹窗点不动、退出登录做不了,刷新依旧——`e4e73f56` 把 `SidebarInset` 搬进 layout 后 `inert={modalOpen}` 罩住了没有 portal 的 `AccountDialogOverlay`(BUG-968,P0,代码级确认)。另含 BUG-969:校正「换一件事问」后无下文,服务端已出下一题且快照重算完整,客户端停在开场状态,GET 路由与客户端两端静默,本单只做可观测 + 不静默 + 题干进正文。基线 `1061514f` | 待领取 | — |
|
||||
| `TASK-account-dialog-inert-20260918.md` | `PROGRESS-account-dialog-inert-20260918.md` | 真机:账户弹窗打开后整个弹窗点不动、退出登录做不了,刷新依旧——`e4e73f56` 把 `SidebarInset` 搬进 layout 后 `inert={modalOpen}` 罩住了没有 portal 的 `AccountDialogOverlay`(BUG-968,P0,代码级确认)。另含 BUG-969:校正「换一件事问」后无下文,服务端已出下一题且快照重算完整,客户端停在开场状态,GET 路由与客户端两端静默,本单只做可观测 + 不静默 + 题干进正文。基线 `1061514f` | 待验收 | `1ace0e64` |
|
||||
| `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — |
|
||||
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
|
||||
| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 真机清单 · 账户弹窗能点 + 校正快照失败可见(2026-09-18)
|
||||
|
||||
对应 `TASK-account-dialog-inert-20260918.md`、`PROGRESS-account-dialog-inert-20260918.md`、BUG-968 / BUG-969。
|
||||
|
||||
自动化做不到的部分:需要**浏览器 + 已登录账号**。执行环境无 Chrome、无登录态,因此这些是环境缺口,不是「已通过」。
|
||||
|
||||
准备:桌面 Chrome,宽度 ≥ 1024px,已登录。不要用 Esc 作为「弹窗能点」的替代证据。
|
||||
|
||||
## A · 五个账户弹窗都能点(BUG-968)
|
||||
|
||||
对侧栏「个人资料 / 星盘资料 / 通用设置 / 账户与点数 / 退出登录」各做一次:
|
||||
|
||||
1. 点开入口。弹窗出现。
|
||||
2. **期望**:关闭按钮能点,点了弹窗关掉;正文区(聊天或输入框)点不动。
|
||||
3. 再打开。「个人资料」改一个昵称能保存;「退出登录」能点「确认退出」(做到确认即可,不必真退)。
|
||||
4. **不期望**:弹窗出现但里面什么都点不了;只能靠 Esc 关掉。
|
||||
|
||||
开着弹窗时,侧栏本身仍可点(它不在 `SidebarInset` 里)。
|
||||
|
||||
## B · 校正「换一件事问」之后有下一题(BUG-969)
|
||||
|
||||
1. 打开一次生时校正,按开场采集题口述两轮带年月的经历。
|
||||
2. 输入「想不到了」发送。
|
||||
3. **期望**:回复落定后出现下一张选择题卡;右侧盘面不再是开场占位;时间轴出现候选点。正文里可以先看到题干,卡挂上后题干不会写两遍。
|
||||
4. **不期望**:只有「已记录。这题先不计分,换一件事问。」然后空白,输入框占位仍是「再说一件带年月的事」。
|
||||
|
||||
## C · 拔网线后再发一轮,要出现状态文案(BUG-969)
|
||||
|
||||
1. 校正会话进行中,DevTools 打开 Network → Offline,或拔网线。
|
||||
2. 再发一轮经历或点一张卡。
|
||||
3. **期望**:校正面出现「这一问还没读到。」和「重新读取」按钮,不是空白、也不是继续装成采集等待。
|
||||
4. 恢复网络,点「重新读取」。**期望**:快照补上,文案消失。
|
||||
@@ -532,6 +532,7 @@ page. Three parts now, in reading order:
|
||||
|
||||
### Settings dialog
|
||||
|
||||
- **Placement:** the overlay and the onboarding paywall portal to `document.body`. `SidebarInset` stays `inert` while a dialog is open (the BUG-744~746 focus contract); the dialog itself must not sit inside that subtree (BUG-968). Closing still uses the existing overlay click, the header button, and the window-level Escape listener.
|
||||
- **Structure:** one fixed chrome for four panes — 个人资料, 星盘资料, 账户与点数, 通用设置. Left nav is 176px and does not scroll; the title bar stays put; only the right-hand content pane scrolls. Logout stays a separate 400px confirmation.
|
||||
- **Width / height:** desktop `width: min(100vw - 32px, 880px)`, with `height: min(84vh, 640px)` as the base and `min(84dvh, 640px)` applied inside `@supports (height: 1dvh)`. All four panes share one class (`.settings-modal`), so switching panes cannot change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top.
|
||||
- **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { warnRectificationCaseGet } from "@/lib/rectification-case-get-log";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
loadV9TurnReceipt,
|
||||
RectificationToolServiceError,
|
||||
listV10ConversationFocuses,
|
||||
safeToolErrorCode,
|
||||
} from "@/lib/rectification-agentic/v9/tool-service";
|
||||
import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response";
|
||||
|
||||
@@ -25,26 +27,49 @@ type RouteContext = { params: Promise<{ caseId: string }> };
|
||||
* The browser restores real history from here; it never reconstructs history
|
||||
* from candidate text or local sentinels.
|
||||
*/
|
||||
function failedCaseGet(
|
||||
caseId: string,
|
||||
status: number,
|
||||
body: Record<string, unknown>,
|
||||
reason: string,
|
||||
) {
|
||||
warnRectificationCaseGet({
|
||||
caseId,
|
||||
status,
|
||||
code: typeof body.code === "string" ? body.code : `http_${status}`,
|
||||
reason,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const { caseId } = await context.params;
|
||||
const caseIdSafe = z.string().uuid().safeParse(caseId).success ? caseId : "";
|
||||
let supabase;
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]");
|
||||
const response = jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]");
|
||||
warnRectificationCaseGet({
|
||||
caseId: caseIdSafe,
|
||||
status: response.status,
|
||||
code: "service_unavailable",
|
||||
reason: safeToolErrorCode(error),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
return failedCaseGet(caseIdSafe, 401, { error: "请先登录" }, authError ? safeToolErrorCode(authError) : "unauthenticated");
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
|
||||
if (!caseIdSafe) {
|
||||
return failedCaseGet(caseId, 400, { error: "请求内容不正确", code: "invalid_case_id" }, "invalid_case_id");
|
||||
}
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const sessionId = searchParams.get("sessionId") ?? "";
|
||||
@@ -53,7 +78,12 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
|
||||
if (sessionId && dossier.case.sessionId !== sessionId) {
|
||||
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
|
||||
return failedCaseGet(
|
||||
caseId,
|
||||
409,
|
||||
{ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" },
|
||||
"case_session_mismatch",
|
||||
);
|
||||
}
|
||||
const skillIdentity = await loadV9CaseSkillIdentityStatus(accounting, user.id, caseId);
|
||||
const receipts = await Promise.all(
|
||||
@@ -71,12 +101,19 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
const message = error.message;
|
||||
if (message.includes("agentic_rectification_case_not_found")) {
|
||||
return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 });
|
||||
return failedCaseGet(
|
||||
caseId,
|
||||
404,
|
||||
{ error: "校正记录不存在或无权访问", code: "case_not_found" },
|
||||
safeToolErrorCode(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
return failedCaseGet(
|
||||
caseId,
|
||||
503,
|
||||
{ error: "暂时无法读取校正记录", code: "rectification_service_failed" },
|
||||
{ status: 503 },
|
||||
safeToolErrorCode(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ChevronRight, Settings, UserRound, Users, WalletCards, X } from "lucide-react";
|
||||
import { memo, type MutableRefObject, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general";
|
||||
|
||||
@@ -60,7 +61,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
||||
return model.renderGeneral();
|
||||
};
|
||||
|
||||
return (
|
||||
const overlay = (
|
||||
<div className="account-modal-overlay" onMouseDown={model.close}>
|
||||
<section
|
||||
className={`account-modal ${model.dialogClass}`}
|
||||
@@ -110,4 +111,9 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
// BUG-968: SidebarInset is inert while the dialog is open. Portal to body so
|
||||
// the dialog is not inside that subtree. SSR keeps the in-tree markup so
|
||||
// renderToString contracts still see the dialog.
|
||||
if (typeof document === "undefined") return overlay;
|
||||
return createPortal(overlay, document.body);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Gift, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { keepFocusWithin } from "@/lib/focus-trap";
|
||||
import { notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership";
|
||||
|
||||
@@ -72,7 +73,7 @@ export function OnboardingRedeemPaywall({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
const overlay = (
|
||||
<div className="account-modal-overlay" onMouseDown={close}>
|
||||
<section
|
||||
className="account-modal paywall-modal"
|
||||
@@ -133,4 +134,6 @@ export function OnboardingRedeemPaywall({
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
if (typeof document === "undefined") return overlay;
|
||||
return createPortal(overlay, document.body);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_SNAPSHOT_RETRY_LABEL,
|
||||
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
|
||||
RECTIFICATION_DELIVERED_COPY,
|
||||
isAbortError,
|
||||
@@ -74,6 +76,7 @@ import {
|
||||
interviewSessionOutcomeFromSnapshot,
|
||||
birthTimeSourceFromSnapshot,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
type RectificationSnapshotFailure,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { RectificationTimeline } from "@/components/rectification-timeline";
|
||||
import {
|
||||
@@ -430,6 +433,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
|
||||
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
|
||||
const [questionRepairing, setQuestionRepairing] = useState(false);
|
||||
const [snapshotFailure, setSnapshotFailure] = useState<RectificationSnapshotFailure | null>(null);
|
||||
const [openingRequested, setOpeningRequested] = useState(false);
|
||||
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||||
@@ -633,11 +637,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store", signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) return undefined;
|
||||
if (controller.signal.aborted) return undefined;
|
||||
if (!response.ok) {
|
||||
setSnapshotFailure({ status: response.status });
|
||||
return undefined;
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (controller.signal.aborted) return undefined;
|
||||
const turns = snapshotTurns(payload);
|
||||
startTransition(() => {
|
||||
setSnapshotFailure(null);
|
||||
applyCaseSnapshot(payload);
|
||||
mergeMessages?.(turns);
|
||||
});
|
||||
@@ -645,8 +654,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
question: currentQuestionFromSnapshot(payload?.current_question),
|
||||
turns,
|
||||
};
|
||||
} catch {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || controller.signal.aborted) return undefined;
|
||||
setSnapshotFailure({ status: "network" });
|
||||
return undefined;
|
||||
} finally {
|
||||
if (snapshotAbort.current === controller) snapshotAbort.current = null;
|
||||
@@ -1779,7 +1789,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
{RECTIFICATION_DELIVERED_COPY}
|
||||
</p>
|
||||
)}
|
||||
{questionGap === "unavailable" && (
|
||||
{snapshotFailure && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="outline" onClick={() => void refetchQuestion()}>
|
||||
{RECTIFICATION_SNAPSHOT_RETRY_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{questionGap === "unavailable" && !snapshotFailure && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}</p>
|
||||
@@ -1828,7 +1846,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? "点上面的选项即可;想补一句细节再写"
|
||||
: (liveQuestionOnMessages || questionGap === "persisted_question") && collectSpokenPrompt
|
||||
? "请回答上面的问题…"
|
||||
: questionGap === "collect_waiting"
|
||||
: !snapshotFailure && questionGap === "collect_waiting"
|
||||
? RECTIFICATION_COLLECT_WAITING_PLACEHOLDER
|
||||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||||
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
|
||||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
|
||||
rectificationCaseHref,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
@@ -188,11 +189,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
async function refreshRectificationCase(caseId: string, sessionId: string) {
|
||||
try {
|
||||
const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
if (!response.ok) {
|
||||
setComposerNotice(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
|
||||
} catch {
|
||||
// History refresh is best-effort; the stream restores live turns.
|
||||
setComposerNotice(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
} from "./server-focus";
|
||||
import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model";
|
||||
import { composeCollectSpokenAssistantText } from "./collect-prompt";
|
||||
import { renderableChoiceStem } from "./turn-question";
|
||||
import {
|
||||
blockingMethodsCovered,
|
||||
buildMethodFollowupPlan,
|
||||
@@ -1547,8 +1548,9 @@ export type CollectDenialApplied = Awaited<ReturnType<typeof applyCollectFocusDe
|
||||
|
||||
/**
|
||||
* After a collect "没有", persist the deterministic turn then bind the next
|
||||
* focus to that turn so the stem can hang on the message. Stream only the
|
||||
* acknowledgment; the server-owned stem joins via asked_turn_id (BUG-525).
|
||||
* focus to that turn so the stem can hang on the message. Collect spoken
|
||||
* still streams the acknowledgment only (BUG-525); a renderable choice
|
||||
* streams ack+stem so the next question is not GET-only (BUG-969).
|
||||
*/
|
||||
export async function persistCollectDenialTurn(input: {
|
||||
accounting: AccountingClient;
|
||||
@@ -1567,7 +1569,8 @@ export async function persistCollectDenialTurn(input: {
|
||||
const stored = hasNextStem
|
||||
? composeCollectSpokenAssistantText(ack, input.applied.narration)
|
||||
: input.applied.narration;
|
||||
const streamText = hasNextStem ? ack : input.applied.narration;
|
||||
const choiceStem = renderableChoiceStem(input.applied.focus);
|
||||
const streamText = hasNextStem && choiceStem ? stored : hasNextStem ? ack : input.applied.narration;
|
||||
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: input.requestId,
|
||||
userMessage: input.userMessage,
|
||||
@@ -2285,6 +2288,15 @@ ${nextInterview.hostNarration}`;
|
||||
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
|
||||
}
|
||||
|
||||
const choiceStem = nextChoiceReady ? renderableChoiceStem(nextFocus) : null;
|
||||
if (choiceStem) {
|
||||
const body = skipThisProbe
|
||||
? composeCollectSpokenAssistantText(input.narration, hostNarration)
|
||||
: hostNarration;
|
||||
hostNarration = composeCollectSpokenAssistantText(body, choiceStem);
|
||||
nextInterviewPersisted = true;
|
||||
}
|
||||
|
||||
if (
|
||||
command.deferFollowup !== true
|
||||
&& (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted }))
|
||||
|
||||
@@ -42,6 +42,15 @@ export function turnQuestionKind(focus: {
|
||||
return "collect_spoken";
|
||||
}
|
||||
|
||||
export function renderableChoiceStem(focus: ConversationFocus | null | undefined): string | null {
|
||||
if (!focus) return null;
|
||||
const question = turnQuestionFromFocus(focus);
|
||||
if (!question) return null;
|
||||
if (question.kind !== "choice" && question.kind !== "reverse_verify") return null;
|
||||
const prompt = question.prompt.trim();
|
||||
return prompt || null;
|
||||
}
|
||||
|
||||
export function turnQuestionFromFocus(focus: ConversationFocus): TurnQuestion | null {
|
||||
const prompt = focusSpokenPrompt(focus.expectedAnswerSchema);
|
||||
if (!prompt || !focus.id || !focus.questionId) return null;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* GET /api/rectification/cases/[caseId] used to return 4xx/5xx with no log.
|
||||
* Every non-200 now emits one JSON warn so a silent client miss is still
|
||||
* visible in the server log (BUG-969). Fields stay non-PII.
|
||||
*/
|
||||
export function warnRectificationCaseGet(input: Readonly<{
|
||||
caseId: string;
|
||||
status: number;
|
||||
code: string;
|
||||
reason: string;
|
||||
}>): void {
|
||||
console.warn(JSON.stringify({
|
||||
event: "rectification_case_get_failed",
|
||||
case_id: input.caseId,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
reason: input.reason,
|
||||
}));
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问
|
||||
export const RECTIFICATION_COLLECT_WAITING_PLACEHOLDER = "再说一件带年月的事";
|
||||
export const RECTIFICATION_DELIVERED_COPY = "再问下去也分不开了。范围在上面,对不上可以改选。";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
|
||||
export const RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY = "这一问还没读到。";
|
||||
export const RECTIFICATION_SNAPSHOT_RETRY_LABEL = "重新读取";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
|
||||
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
|
||||
@@ -185,11 +187,16 @@ export function parsePersistedRectificationTurns(value: unknown): PersistedRecti
|
||||
});
|
||||
}
|
||||
|
||||
export type RectificationSnapshotFailure = Readonly<{
|
||||
status: number | "network" | "timeout";
|
||||
}>;
|
||||
|
||||
export type RectificationCaseHydration = Readonly<{
|
||||
turns: PersistedRectificationTurn[];
|
||||
snapshot: RectificationCaseSnapshotPayload | null;
|
||||
/** False when the request failed or the deadline passed first. */
|
||||
complete: boolean;
|
||||
failure?: RectificationSnapshotFailure;
|
||||
}>;
|
||||
|
||||
export function rectificationCaseHref(caseId: string, sessionId: string): string {
|
||||
@@ -216,7 +223,12 @@ export async function hydrateRectificationCase(
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const schedule = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const cancel = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
||||
const incomplete: RectificationCaseHydration = { turns: [], snapshot: null, complete: false };
|
||||
const incomplete: RectificationCaseHydration = {
|
||||
turns: [],
|
||||
snapshot: null,
|
||||
complete: false,
|
||||
failure: { status: "timeout" },
|
||||
};
|
||||
|
||||
let deadline: unknown;
|
||||
const timeout = new Promise<RectificationCaseHydration>((resolve) => {
|
||||
@@ -225,16 +237,20 @@ export async function hydrateRectificationCase(
|
||||
const read = (async (): Promise<RectificationCaseHydration> => {
|
||||
try {
|
||||
const response = await fetchImpl(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
||||
if (!response.ok) return incomplete;
|
||||
if (!response.ok) {
|
||||
return { turns: [], snapshot: null, complete: false, failure: { status: response.status } };
|
||||
}
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!isRectificationCaseSnapshotPayload(payload)) return incomplete;
|
||||
if (!isRectificationCaseSnapshotPayload(payload)) {
|
||||
return { turns: [], snapshot: null, complete: false, failure: { status: "network" } };
|
||||
}
|
||||
return {
|
||||
turns: parsePersistedRectificationTurns(payload.turns),
|
||||
snapshot: payload,
|
||||
complete: true,
|
||||
};
|
||||
} catch {
|
||||
return incomplete;
|
||||
return { turns: [], snapshot: null, complete: false, failure: { status: "network" } };
|
||||
}
|
||||
})();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createElement, createRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
AccountDialogOverlay,
|
||||
type AccountOverlayModel,
|
||||
} from "../src/components/account-dialog-overlay.tsx";
|
||||
import { warnRectificationCaseGet } from "../src/lib/rectification-case-get-log.ts";
|
||||
import {
|
||||
attachQuestionsToTurns,
|
||||
renderableChoiceStem,
|
||||
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
import { persistCollectDenialTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import {
|
||||
RECTIFICATION_SNAPSHOT_RETRY_LABEL,
|
||||
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
|
||||
} from "../src/lib/rectification-surface-state.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
FOCUS_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
fakeAccounting,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
|
||||
const EXISTENCE_OPTIONS = [
|
||||
{ key: "A" as const, label: "明确发生且时间吻合", answer_class: "yes" },
|
||||
{ key: "B" as const, label: "发生过但时间偏了", answer_class: "weak_yes" },
|
||||
{ key: "C" as const, label: "没有这回事", answer_class: "no" },
|
||||
{ key: "D" as const, label: "这段记不清楚", answer_class: "unsure" },
|
||||
];
|
||||
|
||||
function choiceSchema(prompt: string) {
|
||||
return {
|
||||
prompt,
|
||||
probe_id: "probe:finance:2025-09",
|
||||
choice: {
|
||||
prompt,
|
||||
option_a: EXISTENCE_OPTIONS[0]!.label,
|
||||
option_b: EXISTENCE_OPTIONS[1]!.label,
|
||||
option_c: EXISTENCE_OPTIONS[2]!.label,
|
||||
option_d: EXISTENCE_OPTIONS[3]!.label,
|
||||
options: EXISTENCE_OPTIONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function overlayModel(): AccountOverlayModel {
|
||||
return {
|
||||
title: "个人资料",
|
||||
dialogClass: "settings-modal",
|
||||
signingOut: false,
|
||||
close() {},
|
||||
navigate() {},
|
||||
overlayRef: createRef<HTMLElement | null>() as AccountOverlayModel["overlayRef"],
|
||||
closeButtonRef: createRef<HTMLButtonElement | null>() as AccountOverlayModel["closeButtonRef"],
|
||||
renderProfile() { return null; },
|
||||
renderChartLibrary() { return null; },
|
||||
renderBilling() { return null; },
|
||||
renderGeneral() { return null; },
|
||||
renderLogout() { return null; },
|
||||
};
|
||||
}
|
||||
|
||||
test("the account dialog and onboarding paywall portal to document.body, outside SidebarInset inert (BUG-968)", () => {
|
||||
const overlay = read("../src/components/account-dialog-overlay.tsx");
|
||||
const paywall = read("../src/components/onboarding-redeem-paywall.tsx");
|
||||
const layout = read("../src/app/(app)/layout.tsx");
|
||||
const shell = read("../src/hooks/use-home-shell-registration.ts");
|
||||
const sidebar = read("../src/components/ui/sidebar.tsx");
|
||||
|
||||
assert.match(layout, /<SidebarInset[\s\S]*inert=\{registration\?\.insetInert\}[\s\S]*>\s*\{children\}\s*<\/SidebarInset>/);
|
||||
assert.match(shell, /insetInert: modalOpen/);
|
||||
assert.match(sidebar, /inert=\{Boolean\(inert\) \|\| \(isMobile && openMobile\)\}/);
|
||||
assert.doesNotMatch(layout, /pointer-events:\s*none/);
|
||||
assert.match(overlay, /createPortal\(overlay, document\.body\)/);
|
||||
assert.match(paywall, /createPortal\(overlay, document\.body\)/);
|
||||
|
||||
const html = renderToString(createElement(AccountDialogOverlay, {
|
||||
open: true,
|
||||
dialog: "logout",
|
||||
model: overlayModel(),
|
||||
}));
|
||||
assert.match(html, /role="dialog"/);
|
||||
assert.doesNotMatch(html, /inert/);
|
||||
});
|
||||
|
||||
test("GET /api/rectification/cases/[caseId] warns JSON on every non-200 branch (BUG-969)", () => {
|
||||
const route = read("../src/app/api/rectification/cases/[caseId]/route.ts");
|
||||
assert.match(route, /function failedCaseGet/);
|
||||
assert.match(route, /warnRectificationCaseGet/);
|
||||
assert.match(route, /jsonForSupabaseSetupFailure[\s\S]*warnRectificationCaseGet/);
|
||||
assert.ok((route.match(/failedCaseGet\(/g) ?? []).length >= 5, "401/400/409/404/503 must all warn");
|
||||
assert.match(route, /unauthenticated/);
|
||||
assert.match(route, /invalid_case_id/);
|
||||
assert.match(route, /case_session_mismatch/);
|
||||
assert.match(route, /case_not_found/);
|
||||
assert.match(route, /rectification_service_failed/);
|
||||
|
||||
const logged: string[] = [];
|
||||
const original = console.warn;
|
||||
console.warn = (message?: unknown) => {
|
||||
logged.push(String(message));
|
||||
};
|
||||
try {
|
||||
warnRectificationCaseGet({
|
||||
caseId: "11111111-1111-4111-8111-111111111111",
|
||||
status: 503,
|
||||
code: "rectification_service_failed",
|
||||
reason: "timeout",
|
||||
});
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
assert.equal(logged.length, 1);
|
||||
const payload = JSON.parse(logged[0] ?? "{}") as Record<string, unknown>;
|
||||
assert.equal(payload.event, "rectification_case_get_failed");
|
||||
assert.equal(payload.status, 503);
|
||||
assert.equal(payload.code, "rectification_service_failed");
|
||||
assert.equal(payload.reason, "timeout");
|
||||
assert.equal(payload.case_id, "11111111-1111-4111-8111-111111111111");
|
||||
assert.equal(JSON.stringify(payload).includes("@"), false);
|
||||
});
|
||||
|
||||
test("a 503 snapshot miss shows status copy and 重新读取, not collect-waiting (BUG-969)", () => {
|
||||
const chat = read("../src/components/rectification-agentic-chat.tsx");
|
||||
assert.equal(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY, "这一问还没读到。");
|
||||
assert.equal(RECTIFICATION_SNAPSHOT_RETRY_LABEL, "重新读取");
|
||||
assert.match(chat, /RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /RECTIFICATION_SNAPSHOT_RETRY_LABEL/);
|
||||
assert.match(chat, /setSnapshotFailure\(\{ status: response\.status \}\)/);
|
||||
assert.match(chat, /onClick=\{\(\) => void refetchQuestion\(\)\}/);
|
||||
assert.match(chat, /!snapshotFailure && questionGap === "collect_waiting"/);
|
||||
assert.doesNotMatch(chat, /题目加载失败,请刷新/);
|
||||
const hook = read("../src/hooks/use-rectification-surface.ts");
|
||||
assert.match(hook, /RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY/);
|
||||
});
|
||||
|
||||
test("collect denial with a next choice streams ack+stem; attachQuestionsToTurns keeps the stem once (BUG-969)", async () => {
|
||||
const prompt = "2025 年 9 月前后,钱的方面有没有明显变化?";
|
||||
const ack = RECTIFICATION_USER_COPY.collectDeclinedAck;
|
||||
const schema = choiceSchema(prompt);
|
||||
const focus = {
|
||||
id: FOCUS_ID,
|
||||
caseId: CASE_ID,
|
||||
questionId: "distinguish:finance:2025-09",
|
||||
intent: "distinguish_candidates",
|
||||
targetEvidenceId: null,
|
||||
targetDomain: "finance",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: schema,
|
||||
status: "active" as const,
|
||||
askedAt: "2026-09-18T09:46:00.000Z",
|
||||
resolvedAt: null,
|
||||
askedTurnId: null,
|
||||
answerOption: null,
|
||||
};
|
||||
assert.equal(renderableChoiceStem(focus), prompt);
|
||||
|
||||
const accounting = fakeAccounting({
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: { ...focus, asked_turn_id: args.p_asked_turn_id, askedTurnId: args.p_asked_turn_id },
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const result = await persistCollectDenialTurn({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
requestId: "99999999-9999-4999-8999-999999999999",
|
||||
userMessage: "想不到了",
|
||||
applied: {
|
||||
narration: prompt,
|
||||
ack,
|
||||
closeStatus: "skipped",
|
||||
nextInterviewPersisted: true,
|
||||
nextChoiceReady: true,
|
||||
focus,
|
||||
},
|
||||
});
|
||||
const stored = composeCollectSpokenAssistantText(ack, prompt);
|
||||
assert.equal(result.streamText, stored);
|
||||
assert.match(result.streamText, /2025 年 9 月/);
|
||||
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
||||
assert.equal(turn?.args.p_assistant_message, stored);
|
||||
|
||||
const attached = attachQuestionsToTurns(
|
||||
[{ id: TURN_ID, role: "assistant", text: stored }],
|
||||
[{ ...focus, askedTurnId: TURN_ID }],
|
||||
);
|
||||
assert.equal(attached[0]?.text, ack);
|
||||
assert.equal(attached[0]?.question?.prompt, prompt);
|
||||
assert.equal(attached[0]?.question?.kind, "choice");
|
||||
assert.equal((attached[0]?.text ?? "").includes(prompt), false);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
|
||||
import {
|
||||
CHOICE_ACTION,
|
||||
@@ -808,7 +809,14 @@ test("answering a discriminator persists the next dated card so GET still has a
|
||||
assert.ok(persistedPrompt);
|
||||
assert.notEqual(applied.narration, persistedPrompt);
|
||||
assert.match(applied.narration, /已记录,/);
|
||||
assert.doesNotMatch(applied.narration, /2014/);
|
||||
// 原值: doesNotMatch(applied.narration, /2014/)
|
||||
// 新值: 正文是 ack+stem,含 2014
|
||||
// 原因: BUG-969 可渲染选择题不得只靠 GET 挂题干
|
||||
assert.match(applied.narration, /2014/);
|
||||
assert.equal(
|
||||
applied.narration,
|
||||
composeCollectSpokenAssistantText("已记录,范围没变;05:10 领先,05:00 落后。", persistedPrompt),
|
||||
);
|
||||
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
||||
assert.equal(turn?.args.p_assistant_message, applied.narration);
|
||||
assert.equal(
|
||||
@@ -929,7 +937,11 @@ test("one unsure discriminator answer does not adopt and persists the next quest
|
||||
expectedRevision: twoProbeInference().revision,
|
||||
});
|
||||
assert.equal(applied.answerClass, "unsure");
|
||||
assert.equal(applied.narration, "已记录。这题先不计分,换一件事问。");
|
||||
// 原值: applied.narration === "已记录。这题先不计分,换一件事问。"
|
||||
// 新值: 同一句后面追加下一题题干
|
||||
// 原因: BUG-969 确定性回复流里要带选择题干
|
||||
assert.match(applied.narration, /^已记录。这题先不计分,换一件事问。/);
|
||||
assert.match(applied.narration, /2014 年前后/);
|
||||
assert.notEqual(applied.nextAction.session_outcome, "adopt_representative");
|
||||
assert.equal(applied.nextAction.type, "ask_candidate_discriminator");
|
||||
assert.equal(applied.nextInterviewPersisted, true);
|
||||
|
||||
@@ -1383,9 +1383,15 @@ test("has_new_dated_event continues into the agent after applying the answer", (
|
||||
assert.match(persistDenial, /linkFocusAskedTurn/);
|
||||
// 旧:streamText 写死 collectDeclinedAck → 新:用 applied.ack,跳过走 collectSkippedAck
|
||||
// 原因:BUG-605 「记不清」回执与「没有」不同
|
||||
assert.match(persistDenial, /streamText = hasNextStem \? ack/);
|
||||
// 原值: streamText = hasNextStem ? ack
|
||||
// 新值: 下一题是可渲染选择题时 stream 存档正文(ack+stem),口述采集仍只 stream ack
|
||||
// 原因: BUG-969 选择题不得只靠 GET 挂题干
|
||||
assert.match(persistDenial, /hasNextStem && choiceStem \? stored/);
|
||||
assert.match(persistDenial, /renderableChoiceStem/);
|
||||
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
|
||||
assert.match(persistApplied, /command\.deferFollowup !== true/);
|
||||
assert.match(persistApplied, /renderableChoiceStem\(nextFocus\)/);
|
||||
assert.match(persistApplied, /composeCollectSpokenAssistantText\(body, choiceStem\)/);
|
||||
assert.equal(shouldContinueAgentForDatedEvent({
|
||||
intent: "answer_current_focus",
|
||||
answer_class: "no",
|
||||
|
||||
@@ -104,7 +104,15 @@ test("the question gap is a live row with retries, then a reload; it never tells
|
||||
assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion|showQuestionLoadFailed/);
|
||||
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{/);
|
||||
assert.match(chat, /questionGap === "preparing" && \(/);
|
||||
assert.match(chat, /questionGap === "unavailable" && \(/);
|
||||
// 原值: questionGap === "unavailable" && (
|
||||
// 新值: questionGap === "unavailable" && !snapshotFailure && (
|
||||
// 原因: BUG-969 快照失败走「重新读取」,不得和「接着问」叠两套
|
||||
assert.match(chat, /questionGap === "unavailable" && !snapshotFailure && \(/);
|
||||
assert.match(chat, /snapshotFailure && \(/);
|
||||
assert.match(chat, /RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /RECTIFICATION_SNAPSHOT_RETRY_LABEL/);
|
||||
assert.match(chat, /setSnapshotFailure\(\{ status: response\.status \}\)/);
|
||||
assert.match(chat, /setSnapshotFailure\(\{ status: "network" \}\)/);
|
||||
// 原值:questionGap === "verified_idle" && (
|
||||
// 新值:questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
|
||||
// 原因:BUG-620 把收尾句挪到对照卡下,无 copy 或卡仍在时不单独贴一段;门禁仍锁旧字面量会红(BUG-622)
|
||||
|
||||
@@ -297,9 +297,12 @@ test("hydration reads turns and snapshot from one Case read and is one constant
|
||||
});
|
||||
|
||||
test("hydration resolves incomplete on failure and when the deadline passes first", async () => {
|
||||
const failing = (async () => new Response("nope", { status: 500 })) as typeof fetch;
|
||||
const failing = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
||||
const failed = await hydrateRectificationCase("case-1", "session-1", { fetchImpl: failing, timeoutMs: 1_000 });
|
||||
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false });
|
||||
// 原值: { turns: [], snapshot: null, complete: false }
|
||||
// 新值: 同上并带 failure.status(HTTP 状态或 timeout / network)
|
||||
// 原因: BUG-969 快照失败不得静默,调用方要能区分超时和 503
|
||||
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false, failure: { status: 503 } });
|
||||
|
||||
let fire: (() => void) | undefined;
|
||||
const never = (() => new Promise<Response>(() => {})) as typeof fetch;
|
||||
@@ -314,5 +317,5 @@ test("hydration resolves incomplete on failure and when the deadline passes firs
|
||||
});
|
||||
assert.ok(fire);
|
||||
fire?.();
|
||||
assert.deepEqual(await late, { turns: [], snapshot: null, complete: false });
|
||||
assert.deepEqual(await late, { turns: [], snapshot: null, complete: false, failure: { status: "timeout" } });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user