校正会话被 messages<>'[]' 整类过滤掉、副标题与排序不同源、 新建复用旧空草稿。产品拍板副标题改最后活动时间、第一问前不落库。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
16 KiB
TASK 会话列表重建:校正会话消失、显示时间与排序不同源、空会话落库
- 日期:2026-09-21
- 基线 commit:
0c3c9d6b(origin/staging,与https://staging.jyotisha.chat/api/health的deployment.gitCommit一致) - 分支:
codex/session-list-rebuild-20260921(单分支串行,三个任务都动frontend/src/app/api/sessions/route.ts,不得并行开两个 worktree) - BUG 编号起点:BUG-987(开工时以
docs/BUG_HISTORY.md实际最大号为准,当前最大BUG-986) - 关联记录:BUG-553、BUG-699、BUG-704、BUG-705、BUG-926、BUG-927、BUG-928、BUG-929、BUG-933
1. 事故实证
产品负责人 2026-09-21 15:56 在 staging.jyotisha.chat 的侧栏截图,列表只剩 6 条:
| 位置 | 标题 | 副标题显示 | 所在分组 |
|---|---|---|---|
| 1 | 今日节奏 · 9月18日 | 9月18日 08:01 | 最近 7 天 |
| 2 | 新对话(当前选中) | 9月17日 22:53 | 最近 7 天 |
| 3 | 9月16日 · 今日节奏 | 9月7日 13:42 | 最近 7 天 |
| 4 | 我下半年的运势怎么样 职业… | 9月16日 16:01 | 最近 7 天 |
| 5 | 深入看今日 | 9月9日 16:53 | 最近 30 天 |
| 6 | 深入看今日 | 9月9日 16:37 | 最近 30 天 |
三条现象:
- 一条生时校正会话都没有。 产品负责人 09-08 至 09-17 在 staging 跑过数十轮真实校正,全部不在列表里。
- 显示的时间不单调(9/18 → 9/17 → 9/7 → 9/16),且 9/7 的行落在「最近 7 天」分组里(今天 9/21,7 天边界是 9/15)。
- 点「新建对话」没有新建,落到了 9/17 那条旧「新对话」上。
三条都由同一个提交 e4e73f56(2026-09-17,fix(web): 四个页面共用一份会话列表,空会话不入列)引入。
1.1 实证 A:列表过滤把整类校正会话删掉(P0)
frontend/src/app/api/sessions/route.ts,excludeEmptyConsultations():
// Empty consultations are `messages = []`. Rectification rows keep an opening
// turn, so excluding empty arrays leaves them in the list (BUG-928).
return query.not("messages", "eq", []);
注释第二句与代码事实相反。校正会话的 chat_sessions.messages 永远是 [],三条独立证据:
frontend/src/hooks/use-rectification-surface.ts,openRectificationSession()里构造merged: ChatSession时写死messages: [](sessionType: "birth_time_rectification"同一对象字面量内,符号定位:const merged: ChatSession = {)。frontend/src/lib/chat-session-write-contract.ts,chatSessionCreateSchema的messages: z.array(chatMessageSchema).max(0)——创建路径只接受空数组;chatSessionWriteSchema上方注释写明 PATCH「parses this shape so old bundles are accepted, then the messages field is ignored rather than stored」。frontend/supabase/migrations/20260915010000_rectification_touch_chat_session.sql的touch_chat_session_from_rectification_case()只set updated_at = new.last_activity_at,不碰messages。校正的轮次存在 case 表里。
而客户端 frontend/src/lib/session-list-filter.ts 的 isListedSidebarSession() 明确放行:
if (session.sessionType === "birth_time_rectification") return true;
两层规则相反,服务端先把行拿走了,客户端的放行永远轮不到执行。
门禁没拦住的原因:frontend/tests/chat-session-authority.test.ts 只做源码正则断言
assert.match(listRoute, /\.not\("messages", "eq", \[\]\)/),从未拿真实 Postgres 行验证过「哪些行还在」。
深链 ?c=<uuid> 仍能打开校正会话(resolveBootstrapSessionSelection() 回落 urlAction: "lookup" 走单条读取),但侧栏是唯一的发现入口,对用户等于历史全部丢失。
1.2 实证 B:显示的时间不是排序用的时间
- 显示:
frontend/src/lib/session-sidebar-row.ts的sessionSidebarSubtitle()→const created = session.createdAt || session.updatedAt; - 排序:
frontend/src/lib/session-groups.ts的sortSessions()→right.session.updatedAt - left.session.updatedAt - 分组:同文件
recencyKeyFor(updatedAt, now) - 服务端排序:
route.ts的.order("updated_at", { ascending: false }).order("id", { ascending: false }),游标sessionCursorFilter()同样按updated_at
e4e73f56 之前 SESSION_LIST_COLUMNS 里没有 created_at,createdAt 回落到 updatedAt,两者恰好一致;该提交把 created_at 加进列之后就分叉了。
排序链路本身没有缺陷:第 3 行是 9/7 创建、9/16 晚上仍在使用的会话,updated_at 落在第 2 与第 4 之间,位置和分组都正确。用户看到的那一列数字不是排序键,所以列表读起来是乱的。
1.3 实证 C:第一问之前就落库,新建变成复用旧草稿
frontend/src/hooks/use-session-management.ts的startNewChat():无continuedFromSessionId时先findReusableEmptyConsultation(sessions),命中就setActiveSessionId(reusable.id)直接返回,既不 bumpupdated_at也不改created_at。route.ts的 GET 在首页(无游标)时额外查一条session_type = 'consultation' and messages = []作为draft返回;frontend/src/app/(app)/page.tsx的启动流程把它并进nextSessions,所以这条空会话照常出现在侧栏,带着 9/17 的时间。BLOCKED.md第 120 行「会话列表:空咨询延迟落库未做(2026-09-17,BUG-928)」记录了当初的让步:任务书原本要求「第一问前才POST /api/sessions」,因牵动?c=深链与刷新恢复而降级成「服务端过滤 + 复用空会话」。
2. 根因
一句话:e4e73f56 用「messages 是否为空」当作「这条会话有没有内容」的判据,但这个判据只对咨询会话成立;同一提交又把副标题换成另一个时间字段,让列表的可见顺序与真实排序键脱钩。
拆开三层:
- 判据错位。 「空会话」的真实定义是「用户还没开口」。咨询会话恰好可以用
messages = []表达,校正会话不能——它的内容在 case 表里。服务端把一个只对一半数据成立的判据写成了全表过滤。 - 两套过滤规则。 服务端
excludeEmptyConsultations()与客户端isListedSidebarSession()对同一个问题给出相反答案,且没有任何测试跨这两层对断。 - 让步没收口。 BUG-928 让步掉「延迟落库」之后,留下的替代方案(复用空会话)没有处理复用时的时间语义,于是「新建」这个动作在用户眼里变成了「跳到一条旧记录」。
3. 决策记录
产品负责人 2026-09-21 就地拍板,以下两条推翻既有记录,执行方按本节执行,不得以旧记录为由拒改:
- D1(推翻 BUG-929 的「副标题为上海时区创建时间」):侧栏副标题改为最后活动时间(
updatedAt),与排序、分组完全同源。理由:BUG-929 当初引入创建时间是为了替代被删掉的uniquifyHH:MM 后缀(同日多条靠钟点区分),这个目的用最后活动时间同样达到;而与排序键分叉造成的「列表读起来是乱的」是更大的代价。BUG-929 的其余决定(标题格式「生时校正 · M月D日」「今日节奏 · M月D日」、不再追加 HH:MM、旧标题不批量改)全部保留。 - D2(收掉
BLOCKED.md:120的让步):「第一问之前不落库」本轮必须做完。点「新建对话」只在本地开一条,用户真正发出第一句话时才POST /api/sessions。复用已有空会话的逻辑随之删除(findReusableEmptyConsultation及其调用点);服务端 GET 的draft字段随之删除。
产品负责人明确不接受的替代方案(不要再提):副标题同时显示两个日期;把排序改成按创建时间;保留复用但只 bump 时间戳。
4. 硬红线
- 不得用「某列为空」推断会话有没有内容。 列表要不要显示一条会话,判据只能是「用户是否已经开口」,且该判据必须对两种
session_type都成立。校正会话在任何情况下都不得被列表过滤掉——这是 BUG-987 的防复发。 - 显示、排序、分组、游标四者必须用同一个时间字段(
updated_at)。T2 完成后SESSION_LIST_COLUMNS不得再含created_at,让分叉在结构上不可能复发。 - 打开已有会话仍不得改
title/updated_at(BUG-699、BUG-553 的防复发,不得因本轮改动被推翻)。 frontend/src/app/(app)/page.tsx不得增长(AGENTS.md §6);新逻辑进 hook 或lib/。当前行数开工时记录,交付时不得高于该值。- 不得再手写第二套会话列表数据源;
(app)/layout.tsx的 provider 是唯一来源(BUG-927 的防复发)。 - 源码合同测试搬家时两端都要改(BUG-933 的教训):任何断言
page.tsx或route.ts内容的测试,改了实现就必须同步断言。 - 不得顺手升级依赖、不得顺手修不在本任务书里的 warning。
5. 任务分解
T1|列表不得吞掉生时校正会话(BUG-987,P0,先做)
- 把
excludeEmptyConsultations()的过滤条件从「messages <> '[]'」改成「咨询会话且messages = '[]'时才排除」,即校正会话一律保留。实现上是把条件收窄到session_type = 'consultation'这一支,而不是全表按messages过滤。 - 归档视图(
archived=1)走同一条规则,不得出现「正常视图看不到、归档视图能看到」或反之。 - 删掉
route.ts里那句与事实相反的注释,换成说明校正会话的内容不在messages列里。 - 验收标准:
- 新增真实 Postgres 测试
frontend/tests/database-session-list-visibility.test.ts:插入 ①messages = []的咨询、②messages = []的birth_time_rectification、③有内容的咨询、④归档的校正,断言列表查询返回 ②③、不返回 ①,归档视图返回 ④。不得用源码正则代替(这正是 BUG-987 漏网的原因)。无 Docker 时按 §7 让步顺序处理。 frontend/tests/chat-session-authority.test.ts第 16 行那条正则断言同步更新为新写法,并补一条「过滤条件必须带session_type限定」的断言。- 新增跨层对断:服务端过滤规则与
isListedSidebarSession()对「空咨询 / 校正会话 / 归档」三种输入给出相同答案。 - 产品负责人在 staging 侧栏能看到 09-08 以来的校正会话。
- 新增真实 Postgres 测试
T2|显示时间与排序同源(BUG-988)
sessionSidebarSubtitle()改用session.updatedAt(D1)。SESSION_LIST_COLUMNS去掉created_at(硬红线 2)。去掉前先确认列表路径没有第二个createdAt消费者——已核实只有副标题在读;frontend/src/lib/rectification-session-title-repair.ts走的是迁移路径,不经过这个列表。frontend/src/lib/home-cloud-sync.ts的createdAt回落链保持不变(详情接口仍返回created_at),不要顺手删。- 验收标准:
frontend/tests/session-groups.test.ts或新测试断言:对任意一组会话,副标题渲染出的时间序列与sortSessions()的输出顺序单调一致(构造一条「早创建、晚活动」的会话,断言它排在正确位置且副标题显示的是晚活动时间)。- 源码合同断言
SESSION_LIST_COLUMNS不含created_at。 - 截图第 3 行那种情况(9/7 创建、9/16 活动)在 UI 上显示 9月16日,且位置不变。
T3|第一问之前不落库(BUG-989,最后做,牵动面最大)
startNewChat():删除findReusableEmptyConsultation分支,改为只在本地创建会话对象并选中,不发POST /api/sessions。- 首次真正发送消息时(
send()路径)才落库:先POST /api/sessions建行,再走既有的append_consultation_question。落库失败时保持现有的回滚语义(setSessions撤销 +requestError),不得把用户刚打的字弄丢。 route.ts的 GET 删除draft查询与draft字段;(app)/page.tsx删除readDraftConsultation的并入;session-list-context.tsx删除draftRow。?c=<uuid>深链与刷新恢复:本地未落库的会话没有服务端身份,不得把它的 id 写进 URL(现在startNewChat里writeSessionUrl(nextSession.id, "push")会写)。落库成功后再写 URL。刷新时未落库的本地会话消失是可接受的(它本来就是空的),但不得因此报「该对话不存在或已被删除」。- 清理历史遗留:本轮不做存量空会话的批量删除(数据清理另议),但要确认 T1 之后它们仍被列表过滤掉。
- 更新
BLOCKED.md:120那条——按 AGENTS.md §4,解除后划掉而不是删除。 - 验收标准:
frontend/tests/session-list-lifecycle.test.tsx(或新测试)断言:点「新建对话」后没有发生POST /api/sessions;发出第一句话后恰好发生一次;落库失败时本地会话被撤销且草稿文本仍在。- 断言未落库的本地会话不写
?c=,落库成功后才写。 - 断言
route.ts响应体不再含draft、session-list-context.tsx不再有draftRow。 - 产品负责人在 staging 连点五次「新建对话」,侧栏不出现任何「新对话」行;发一句话后出现一行,副标题是今天。
6. 交付前必须全跑
cd frontend && ./node_modules/.bin/tsc --noEmit→ 0 错npm run lint→ 0 errornpm test→ 全量,不是定向。失败清单与基线0c3c9d6b逐条比对,条数不得低于基线(AGENTS.md §7.3)npm run test:db→ 有 Docker 时必跑(T1 新增的是真实 Postgres 测试)npm run build→/仍○ Static;首屏 gzip 与基线相差在 ±2% 内- 本轮不动 Python,不要求
run_quality_gate.py
进度记录写 docs/tasks/PROGRESS-session-list-rebuild-20260921.md,Bug 记录三条(987/988/989)写进 docs/BUG_HISTORY.md,用户可感知的变化写进 CHANGELOG.md,侧栏副标题口径变化同一提交内更新 frontend/DESIGN.md。
7. 让步顺序
按此顺序让步,每让一步都要在进度记录里写明让了什么、替代证据是什么:
- 无 Docker → T1 的
database-session-list-visibility.test.ts跑不了:测试文件照写照提交,在BLOCKED.md记明,并补一条不依赖 Docker 的查询构造合同测试(断言过滤条件里出现session_type限定)作为替代证据。不得因此不写那个真实 DB 测试。 - T3 的第一问落库牵出预料外的
?c=/ 刷新回归 → 允许把 T3 单独拆到后一轮,但 T1、T2 必须本轮交付,且拆分理由要写进进度记录和BLOCKED.md。T1 不得让步。 - 首屏 gzip 超 ±2% → 在进度记录里给出原因,不得为了指标回退功能。
- 任何让步都不得触碰 §4 硬红线 1、2、3。
8. 开工前置命令
cd /workspace/Jyotisha
git status -sb | head -1 # 确认没落在别人的分支上
git fetch origin --prune
git worktree add -b codex/session-list-rebuild-20260921 \
.worktrees/session-list-rebuild-20260921 origin/staging
cd .worktrees/session-list-rebuild-20260921/frontend
npm ci # 若 node_modules 缺失
./node_modules/.bin/tsc --noEmit # 记录基线
npm test 2>&1 | tail -30 # 记录基线失败清单与总条数
grep -n "" src/app/\(app\)/page.tsx | tail -1 # 记录 page.tsx 基线行数
开工时必读:
docs/BUG_HISTORY.md的 BUG-553、BUG-699、BUG-704、BUG-705、BUG-926~929、BUG-933(检索关键词:会话列表、messages = []、空会话、updated_at、draft)BLOCKED.md第 120 行起那条docs/tasks/TASK-session-list-single-source-20260917.md与其PROGRESS(本单是它的返工)AGENTS.md§2、§3、§5、§6、§7
纯前端 + 一条 API route,不要求 scripts/pre_work_check.py(AGENTS.md §9 末段)。