# TASK · 会话标题守卫写库恒不命中(BUG-553 修复单)(2026-09-06) - 基线:`origin/staging` @ `e2f4b55c`(含 BUG-553 实现 `a1956deb`) - 分支:`codex/session-title-guard-fix-20260906` - 执行方:coding agent;验收:Claude - 涉及文件:`frontend/src/app/api/consult/route.ts`(只改标题守卫段)、`frontend/tests/application-billing-contract.test.ts` 或新 `frontend/tests/session-title-persist-contract.test.ts`;不动迁移、不动客户端、不动 `session-title-agent.ts` - BUG 编号起点:**BUG-557**(开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核) - 串行:与 `TASK-consultation-context-and-cache` 的 `route.ts` 改动已合入,无并行冲突 ## 1. 事故实证(代码验收,未上线) `frontend/src/app/api/consult/route.ts`: | 行 | 事实 | | --- | --- | | ~L282 | `chatSession` select 读到会话标题(新会话为空或「新对话」) | | L531 | `accounting.rpc("append_consultation_question")` —— 迁移 `20260901010000_append_consultation_question.sql` L84–90 在标题为空 / 「新对话」时把标题改写成 `left(question,14) || '…'`(≤14 字时为整句) | | L693 | `const expectedTitle = chatSession.title`(RPC **之前**的值) | | L710 | `update({ title }).eq("id").eq("user_id").eq("title", expectedTitle)` | 对任何新会话,L710 的 `title = expectedTitle` 都比不上 L531 已改写的值,更新影响 0 行、Supabase 不报错、`console.warn` 不触发。模型标题只在客户端结算时通过 `persistSession` 的 PATCH 落库;用户中途刷新、关标签、换设备时 `api/consult/status` 回读的是问题截断标题,起名白做。`tests/session-title-agent.test.ts` 与 `application-billing-contract.test.ts` 都没有断言这条守卫会命中。 ## 2. 根因 任务书 §3 决策 3「只在标题仍等于本轮开始时读到的值时更新」写的是「本轮开始」,而本轮真正改标题的是 RPC;执行方按字面取了 RPC 前的快照。守卫本身设计没错,取值时机错。 ## 3. 决策记录 1. 起名触发条件(`shouldGenerateSessionTitle(expectedTitle-before-RPC, storedHistory)`)**不变**:仍用 RPC 之前的标题判断「是否自动派生」,否则 ≤14 字的问题会被当成用户手改标题而不起名。 2. 守卫的比较值改为 **RPC 之后的真实标题**:RPC 成功后再 `select("title")` 一次(一次主键查询,可接受),把该值作为 `expectedTitle` 传给守卫。不用 TS 重算 SQL 规则(`safeQuestionTitle` 与 SQL 的标点处理不同,重算会再次错配)。 3. 用户中途改名以用户为准这条语义保持:守卫仍是 `.eq("title", )`。 4. 守卫更新要 `.select("id")` 回读影响行数,0 行时 `console.warn("session title guard missed")`,不再静默。 ## 4. 硬红线 - 不改 RPC、不改迁移、不改客户端 `use-consultation-run.ts`。 - 不得把守卫放宽成无条件写入。 - 不得为测试重写路由结构;用现有 `application-billing-contract.test.ts` 的源码合同方式或 mock Supabase 链的方式二选一。 ## 5. 任务分解 ### 5.1 守卫取 RPC 后标题 - `route.ts`:`appendedQuestion` 成功后 `const { data: titleRow } = await supabase.from("chat_sessions").select("title").eq("id", sessionId).eq("user_id", userId).maybeSingle()`;`shouldGenerateSessionTitle` 仍用 RPC 前标题;守卫 `.eq("title", titleRow?.title ?? expectedTitle)`;更新后 `.select("id")`,长度 0 → `console.warn`。 - 验收:测试——模拟 RPC 前标题「新对话」、RPC 后标题「问题前 14 字…」、模型标题「X」:断言 update 链的 `eq("title", …)` 收到的是 RPC 后标题;模拟用户中途把标题改成「我的标题」(回读值与守卫值不等)→ 更新 0 行且 `console.warn` 被调用一次、返回的流事件仍是 `session.title`。 ### 5.2 记录 - `docs/BUG_HISTORY.md` BUG-557(关联 BUG-553;说明为何 a1956deb 的测试没拦住:无守卫命中断言);`CHANGELOG.md` 一条;`docs/tasks/PROGRESS-session-title-guard-fix-20260906.md`;`docs/testing/session-list-title-and-order-20260906.md` 补一条「首轮生成中刷新页面,回来后标题仍是模型标题」。 ## 6. 让步顺序 5.1 不可拆;5.2 不可省。 ## 7. 开工前置命令 ```bash git fetch origin --prune git worktree add -b codex/session-title-guard-fix-20260906 .worktrees/session-title-guard-fix-20260906 origin/staging cd .worktrees/session-title-guard-fix-20260906/frontend ln -s /workspace/Jyotisha/frontend/node_modules node_modules # 注意:不要在已有 node_modules 的目录里再建,会产生 node_modules/node_modules 双份 React npx tsx --test tests/session-title-agent.test.ts tests/application-billing-contract.test.ts 2>&1 | grep -E "^# (tests|pass|fail)|^not ok" ``` 收尾同一条命令 fail=0,再 `tsc --noEmit`、`npm run lint`。