fix(ci): 校正守卫后的五条前端源码合同跟着搬家(BUG-940)

This commit is contained in:
jesse-ux
2026-09-18 10:39:23 +08:00
parent c0dd1773f7
commit 0378d9e030
7 changed files with 57 additions and 12 deletions
+17 -1
View File
@@ -12298,6 +12298,22 @@
- 修复:首页表面改为必读 `(app)/page.tsx`hooks 仍可选。新增路径锁:`PAGE.parts` 必须以 `(app)/page.tsx` 结尾,旧路径不得存在。
- 验证:`pytest tests/test_birth_time_journey_contract.py tests/test_session_management_entrypoints.py tests/test_supabase_user_data_contract.py tests/test_daily_and_rectification_entrypoints.py` 25 passed。未改产品代码。
- 防复发:扫首页源码的 Python 合同必须必读 `(app)/page.tsx`,不得对首页文件 `exists()` 跳过。外壳再搬家时,`CORE_PYTEST_TARGETS` 里所有 `_home_surface` 都要一起改。
- 相关记录:BUG-927、BUG-933、BUG-934
- 相关记录:BUG-927、BUG-933、BUG-934、BUG-940
- 复发自:BUG-933
- 修复版本:`39a0d7a9`
## BUG-940 | 校正守卫后 5 条前端源码合同仍按旧写法断言,门禁继续红
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`chat-composer-queue.test.ts``chat-session-url.test.ts``consultation-entrypoint.test.ts``consultation-recovery.test.ts`Gitea `backend-quality-gate` `npm test`
- 用户现象:BUG-939 修好 Python 合同后,门禁 [run 2764](https://git.copse.top/root/Jyotisha/actions/runs/2764) 仍红。`npm test` 3484 条里 5 条源码正则失败。线上仍停在 `dc2f2a16`
- 触发条件:push 到 `staging` 跑全量 `npm test`。先前被 Python 快速门挡住,这 5 条从未在门禁里跑到。
- 根因:BUG-924/935 把校正会话判断收到 `composerLocksAsRectification` / `activateFallbackSession` / `dropRectificationStoredPending`。被保护的性质还在,合同仍按搬家前的字面量断言。BUG-933 只改了另外四条,漏了这五条。
- 修复:按「原值 / 新值 / 原因」改写这五条,不断性质、不删测试。
- 验证:上述四文件 `tsx --test` **58 passed / 0 failed**。未改产品代码。
- 防复发:改 `page.tsx` 或校正守卫字面量的轮次必须跑全量 `npm test`,不得只跑定向。源码合同跟着实现搬家,见 BUG-927。
- 相关记录:BUG-924、BUG-927、BUG-933、BUG-935、BUG-939
- 复发自:BUG-933
- 修复版本:待发布
@@ -9,9 +9,10 @@
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| 路径搬家 BUG-939 | 完成 | `_home_surface` 必读 `(app)/page.tsx`hooks 仍可选 |
| 路径搬家 BUG-939 | 完成 | `_home_surface` 必读 `(app)/page.tsx`hooks 仍可选。合入 `39a0d7a9`,门禁 run 2764 已过 Python |
| 路径锁 | 完成 | `PAGE.parts[-2:] == ("(app)", "page.tsx")`;旧路径不得存在 |
| 记录 | 完成 | BUG-939(复发自 BUG-933);BUG-933 相关记录补本条 |
| 五条前端合同 BUG-940 | 完成 | run 2764 暴露:校正守卫后源码正则未搬家 |
| 记录 | 完成 | BUG-939 / BUG-940(均复发自 BUG-933 |
未改 `page.tsx`、产品行为、Skill、workflow。
@@ -21,14 +22,20 @@
| --- | --- | --- | --- |
| `_home_surface` 首页文件 | `frontend/src/app/page.tsx`,缺失则 `exists()` 跳过 | 必读 `frontend/src/app/(app)/page.tsx` | `e4e73f56` 已搬家;静默跳过把失败伪装成「找不到 `<BirthTimeRectification`」 |
| 新增 `test_home_surface_requires_the_app_route_group_page` | 无 | 锁定 `(app)/page.tsx` 存在、旧路径不存在 | 防复发:下一次再搬文件会 FileNotFound,而不是子串断言 |
| `chat-composer-queue` `inputDisabled` | `sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| (!profileComplete && …)` | 中间加 `activeRectificationSession` | BUG-924/935 校正占位必须禁用普通输入框 |
| `chat-session-url` 删/归档写 URL | 函数体内 `writeSessionUrl(fallbackId \|\| null, "replace")` | `activateFallbackSession`,由它 `writeSessionUrl(null\|fallbackId, "replace")` | 回退优先非校正,落到校正走 selectSession |
| `consultation-entrypoint` `rectificationSurfaceOpen` | `activeRectificationSession && activeSession.id === …` | `Boolean(activeRectificationSession && activeSession && activeSession.id === …)` | 无活跃会话时不能读 `.id` |
| `consultation-entrypoint` 校正类型 | `activeSession?.sessionType === "birth_time_rectification"` | `composerLocksAsRectification` + 守卫里的 `session?.sessionType` | 类型判断收到守卫 |
| `consultation-recovery` 清 pending | `if (!storedPending && sessionStorage.getItem(…)) { removeItem }` | `dropRectificationStoredPending``if (!storedPending) removeItem` | 校正 pending 不得进普通咨询恢复 |
## 测试
| 项 | 结果 |
| --- | --- |
| `pytest tests/test_birth_time_journey_contract.py tests/test_session_management_entrypoints.py tests/test_supabase_user_data_contract.py tests/test_daily_and_rectification_entrypoints.py` | **25 passed** |
| 前端 tsc / lint / npm test / next build | 未改前端,未跑 |
| 门禁 | 推 staging 后以 `backend-quality-gate` 为准 |
| `tsx --test` 上述四份前端合同 | **58 passed / 0 failed**(含门禁原先 5 红) |
| 全量 `npm test` / tsc / lint / next build | 本机 worktree 无独立 node_modules,以门禁为准 |
| 门禁 run 2764 | Python 已过;`npm test` 5 红即本轮这五条 |
## 环境缺口
+1 -1
View File
@@ -134,7 +134,7 @@
| `TASK-consult-followup-tool-contract-fix-20260917.md` | `PROGRESS-consult-followup-tool-contract-fix-20260917.md` | **P0**。部署 `dc2f2a16` 后再聊天仍 `runtime_contract_incomplete`,回执只有 skill + retry。主根因(推断):BUG-923 把本命/窗口第 0 步改成 `toolChoice: required`,而首轮固定开 thinkingBUG-282 实证 thinking 模式供应商拒收非 auto 的 tool_choice → 每一轮咨询在第 0 步就被拒(BUG-937,复发自 BUG-282)。次根因(确定):咨询流不处理 Mastra `error` 块,拒收在回执/事件/日志三处都不可见,合同 retry 照跑同一参数(BUG-938)。T1 撤回 required 只留 activeTools + autoT2 error 块 → 可诊断错误码、不进合同 retry;T4 部署后取证 | 待验收 | `codex/consult-followup-tool-contract-fix-20260917` |
| `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 验收未通过 | `e4e73f56`(实现按任务书落地:多键排序 / `(app)` 路由组常驻外壳 / provider / 空会话不入列 + draft 复用 / 标题类别在前;两条让步任务书允许。**P1:4 条针对 `page.tsx` 的源码合同没跟着外壳搬家,`npm test` 在门禁里 `set -e` 下必红,staging 至今停在 `dc2f2a16`**。另:进度记录报「tsc 0 错」实为 2 错,只跑了定向测试。修复单 `TASK-session-list-single-source-fix-20260917.md` |
| `TASK-session-list-single-source-fix-20260917.md` | `PROGRESS-session-list-single-source-fix-20260917.md` | 验收修复单:F1 四条源码合同搬到 `(app)/layout.tsx` 两端对断(BUG-933);F2 两条陈旧 Python 入口断言(BUG-934,既有欠账);F3 无活跃会话时输入框静默吞发送(BUG-935);交付前必须跑全量测试 | 待验收 | `codex/session-list-single-source-fix-20260917` |
| — | `PROGRESS-birth-time-journey-page-path-20260918.md` | **P0 门禁**`test_birth_time_journey_contract` 仍读已搬走的 `app/page.tsx``exists()` 静默跳过导致 CORE pytest 红,staging 停在 `dc2f2a16`BUG-939复发自 BUG-933 | 已合入 | `39a0d7a9` |
| — | `PROGRESS-birth-time-journey-page-path-20260918.md` | **P0 门禁**`test_birth_time_journey_contract` 仍读已搬走的 `app/page.tsx`BUG-939);修好后 run 2764 又露出 5 条校正守卫后的前端源码合同BUG-940)。均复发自 BUG-933 | 执行中 | `codex/birth-time-journey-page-path-20260918` |
| `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 不再写留白);真机六条欠 |
+4 -1
View File
@@ -50,9 +50,12 @@ test("generating does not disable the textarea; Enter queues instead of dropping
assert.match(composer, /COMPOSER_QUEUE_RECALL_LABEL/);
assert.doesNotMatch(styles, /composer-queue[\s\S]{0,400}spinner|composer-queue[\s\S]{0,400}skeleton/i);
// 原值:`inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || (!profileComplete && ...)}`
// 新值:中间多了 `activeRectificationSession`
// 原因:BUG-924/935 校正会话占位时普通输入框必须禁用,生成中仍不得 disabled textarea。
assert.match(
page,
/inputDisabled=\{sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| \(!profileComplete && \(onboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
/inputDisabled=\{sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| activeRectificationSession \|\| \(!profileComplete && \(onboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
);
assert.doesNotMatch(page, /inputDisabled=\{isLoading \|\| sessionMessagesLoading \|\| cancellationPending/);
assert.match(page, /if \(isLoading \|\| cancellationPending\) return void enqueueQueuedDraft\(composerDraftSnapshot\(\)\);/);
+9 -2
View File
@@ -177,11 +177,18 @@ test("creating and leaving a session keep the address bar in sync", () => {
assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/);
// 原值:delete/archive 函数体内直接 `writeSessionUrl(fallbackId || null, "replace")`
// 新值:删/归档当前会话走 `activateFallbackSession`,由它 `writeSessionUrl(null|fallbackId, "replace")`
// 原因:BUG-924 回退优先非校正会话,落到校正会话时要走 selectSession,不能在 delete 里写死 URL。
const deleteSession = sourceBetween(page, "async function deleteSession(", "function togglePinnedSession");
assert.match(deleteSession, /writeSessionUrl\(fallbackId \|\| null, "replace"\)/);
assert.match(deleteSession, /if \(activeSessionId === session\.id\) \{\s*activateFallbackSession\(nextSessions\);/);
const archiveSession = sourceBetween(page, "function toggleArchivedSession(", "async function shareSession");
assert.match(archiveSession, /writeSessionUrl\(fallbackId \|\| null, "replace"\)/);
assert.match(archiveSession, /activateFallbackSession\(visibleSessions\.filter\(\(item\) => item\.id !== sessionId\)\)/);
const fallback = sourceBetween(page, "function activateFallbackSession(", "async function loadMoreSessions");
assert.match(fallback, /writeSessionUrl\(null, "replace"\)/);
assert.match(fallback, /writeSessionUrl\(fallbackId, "replace"\)/);
const recovery = sourceBetween(
page,
+10 -2
View File
@@ -205,7 +205,10 @@ test("homepage opens through the server Case API and merges the returned session
assert.ok(reveal > merge);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.doesNotMatch(handler, /onNarrativeDelta/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
// 原值:`const rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId`
// 新值:包一层 `Boolean(...)`,并先判 `activeSession`,避免无活跃会话时读 `.id`
// 原因:BUG-935 列表只剩校正时 activeSession 可为 undefined。
assert.match(source, /const rectificationSurfaceOpen = Boolean\(activeRectificationSession && activeSession && activeSession\.id === rectificationSessionId\)/);
// 原值:同一守卫内 JSX 写 pendingConsultationQuestion={rectificationPendingQuestion}
// 新值:同一守卫内 JSX 写 panel={rectificationPanel}pending 字段在 hook panel
// 原因:校正面子树状态收到 panel
@@ -226,8 +229,13 @@ test("the page never creates the session shell locally; the server owns session
test("rectification cards render only inside the active rectification session", () => {
const source = homeSurface;
const guard = readFileSync(new URL("../src/lib/rectification-session-composer-guard.ts", import.meta.url), "utf8");
assert.match(source, /activeSession\?\.sessionType === "birth_time_rectification"/);
// 原值:page 里 `activeSession?.sessionType === "birth_time_rectification"`
// 新值:Home 读 `composerLocksAsRectification(activeSession, sessions)`,类型判断在守卫 `session?.sessionType === "birth_time_rectification"`
// 原因:BUG-924/935 把「这是不是校正会话」收到守卫,卡片仍只在 `rectificationSurfaceOpen` 里渲染。
assert.match(source, /composerLocksAsRectification\(activeSession, sessions\)/);
assert.match(guard, /session\?\.sessionType === "birth_time_rectification"/);
assert.match(source, /session_type:\s*session\.sessionType/);
assert.match(source, /rectification_case_id:\s*session\.rectificationCaseId/);
assert.doesNotMatch(source, /这个会话保存了生时校正入口|恢复生时校正<\/button>/);
+5 -1
View File
@@ -105,7 +105,11 @@ test("tab-local pending ids drive strict bootstrap recovery before the global fa
);
assert.match(bootstrap, /readStoredPendingConsultation\([\s\S]*pendingConsultationStorageKey[\s\S]*nextSessions\.map\(\(session\) => session\.id\)/);
assert.match(bootstrap, /if \(!storedPending && sessionStorage\.getItem\(pendingConsultationStorageKey\)\) \{\s*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
// 原值:`if (!storedPending && sessionStorage.getItem(...)) { sessionStorage.removeItem(...) }`
// 新值:`if (!storedPending) sessionStorage.removeItem(...)`,读存储前先 `dropRectificationStoredPending`
// 原因:校正会话上的 pending 不得进普通咨询恢复;无 pending 时直接清 key,不必再 getItem。
assert.match(bootstrap, /dropRectificationStoredPending\([\s\S]*readStoredPendingConsultation/);
assert.match(bootstrap, /if \(!storedPending\) sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
assert.ok(bootstrap.indexOf("if (storedPending)") < bootstrap.indexOf("fetchActiveConsultationStatus(controller.signal)"));
assert.match(bootstrap, /if \(storedPending\) \{[\s\S]*fetchConsultationStatus\([\s\S]*storedPending\.sessionId,[\s\S]*storedPending\.requestId,[\s\S]*\} else \{[\s\S]*fetchActiveConsultationStatus/);
assert.match(bootstrap, /status\.status === "reserved"[\s\S]*reservedConsultation = status;[\s\S]*else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);