Files
Jyotisha/docs/tasks/TASK-session-list-rebuild-20260921.md
T
Jesse_ChenandClaude Opus 5 f8d65e484d docs(tasks): 会话列表重建任务书(BUG-987~989)
校正会话被 messages<>'[]' 整类过滤掉、副标题与排序不同源、
新建复用旧空草稿。产品拍板副标题改最后活动时间、第一问前不落库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-21 16:11:04 +08:00

16 KiB
Raw Blame History

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 天

三条现象:

  1. 一条生时校正会话都没有。 产品负责人 09-08 至 09-17 在 staging 跑过数十轮真实校正,全部不在列表里。
  2. 显示的时间不单调(9/18 → 9/17 → 9/7 → 9/16),且 9/7 的行落在「最近 7 天」分组里(今天 9/21,7 天边界是 9/15)。
  3. 点「新建对话」没有新建,落到了 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) 直接返回,既不 bump updated_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 是否为空」当作「这条会话有没有内容」的判据,但这个判据只对咨询会话成立;同一提交又把副标题换成另一个时间字段,让列表的可见顺序与真实排序键脱钩。

拆开三层:

  1. 判据错位。 「空会话」的真实定义是「用户还没开口」。咨询会话恰好可以用 messages = [] 表达,校正会话不能——它的内容在 case 表里。服务端把一个只对一半数据成立的判据写成了全表过滤。
  2. 两套过滤规则。 服务端 excludeEmptyConsultations() 与客户端 isListedSidebarSession() 对同一个问题给出相反答案,且没有任何测试跨这两层对断。
  3. 让步没收口。 BUG-928 让步掉「延迟落库」之后,留下的替代方案(复用空会话)没有处理复用时的时间语义,于是「新建」这个动作在用户眼里变成了「跳到一条旧记录」。

3. 决策记录

产品负责人 2026-09-21 就地拍板,以下两条推翻既有记录,执行方按本节执行,不得以旧记录为由拒改:

  • D1(推翻 BUG-929 的「副标题为上海时区创建时间」):侧栏副标题改为最后活动时间(updatedAt),与排序、分组完全同源。理由:BUG-929 当初引入创建时间是为了替代被删掉的 uniquify HH:MM 后缀(同日多条靠钟点区分),这个目的用最后活动时间同样达到;而与排序键分叉造成的「列表读起来是乱的」是更大的代价。BUG-929 的其余决定(标题格式「生时校正 · M月D日」「今日节奏 · M月D日」、不再追加 HH:MM、旧标题不批量改)全部保留。
  • D2(收掉 BLOCKED.md:120 的让步):「第一问之前不落库」本轮必须做完。点「新建对话」只在本地开一条,用户真正发出第一句话时才 POST /api/sessions。复用已有空会话的逻辑随之删除(findReusableEmptyConsultation 及其调用点);服务端 GET 的 draft 字段随之删除。

产品负责人明确不接受的替代方案(不要再提):副标题同时显示两个日期;把排序改成按创建时间;保留复用但只 bump 时间戳。


4. 硬红线

  1. 不得用「某列为空」推断会话有没有内容。 列表要不要显示一条会话,判据只能是「用户是否已经开口」,且该判据必须对两种 session_type 都成立。校正会话在任何情况下都不得被列表过滤掉——这是 BUG-987 的防复发。
  2. 显示、排序、分组、游标四者必须用同一个时间字段(updated_at)。T2 完成后 SESSION_LIST_COLUMNS 不得再含 created_at,让分叉在结构上不可能复发。
  3. 打开已有会话仍不得改 title / updated_at(BUG-699、BUG-553 的防复发,不得因本轮改动被推翻)。
  4. frontend/src/app/(app)/page.tsx 不得增长(AGENTS.md §6);新逻辑进 hook 或 lib/。当前行数开工时记录,交付时不得高于该值。
  5. 不得再手写第二套会话列表数据源;(app)/layout.tsx 的 provider 是唯一来源(BUG-927 的防复发)。
  6. 源码合同测试搬家时两端都要改(BUG-933 的教训):任何断言 page.tsx 或 route.ts 内容的测试,改了实现就必须同步断言。
  7. 不得顺手升级依赖、不得顺手修不在本任务书里的 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 以来的校正会话。

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 error
  • npm 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. 让步顺序

按此顺序让步,每让一步都要在进度记录里写明让了什么、替代证据是什么:

  1. 无 Docker → T1 的 database-session-list-visibility.test.ts 跑不了:测试文件照写照提交,在 BLOCKED.md 记明,并补一条不依赖 Docker 的查询构造合同测试(断言过滤条件里出现 session_type 限定)作为替代证据。不得因此不写那个真实 DB 测试。
  2. T3 的第一问落库牵出预料外的 ?c= / 刷新回归 → 允许把 T3 单独拆到后一轮,但 T1、T2 必须本轮交付,且拆分理由要写进进度记录和 BLOCKED.md。T1 不得让步。
  3. 首屏 gzip 超 ±2% → 在进度记录里给出原因,不得为了指标回退功能。
  4. 任何让步都不得触碰 §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 末段)。