From 0102a973a36042b68b301c6669969cc4eb747abe Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 6 Sep 2026 19:22:43 +0800 Subject: [PATCH] fix(web): persist generated session titles against the post-RPC title (BUG-557) The first-round title guard compared the pre-RPC snapshot, so append_consultation_question had already rewritten the title and the model name never landed. Co-authored-by: Cursor --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 16 +++ ...OGRESS-session-title-guard-fix-20260906.md | 41 +++++++ docs/tasks/README.md | 2 +- .../session-list-title-and-order-20260906.md | 8 ++ frontend/src/app/api/consult/route.ts | 27 +++-- frontend/src/lib/session-title-guard.ts | 40 +++++++ .../application-billing-contract.test.ts | 7 ++ .../session-title-persist-contract.test.ts | 109 ++++++++++++++++++ 9 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 docs/tasks/PROGRESS-session-title-guard-fix-20260906.md create mode 100644 frontend/src/lib/session-title-guard.ts create mode 100644 frontend/tests/session-title-persist-contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 285a4f7f..8d57072b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-06 — 首轮咨询标题在刷新前就会落库 + +新开的普通咨询,模型起的主题标题会写进服务器。生成中途刷新或换设备,侧栏仍是那句总结,而不是问题前几个字加省略号。你中途自己改名的,仍以你改的为准。Skill 版本未变。 + ## 2026-09-06 — 设置弹窗固定尺寸,账户与点数进弹窗 设置弹窗四个分区共用一个外框,切换时不再跳变。星盘资料先出列表,点进去才编辑或添加。账户与点数成为第四个分区,套餐、点数包、兑换码和订单都在里面完成;原来的 `/membership` 页面删除,旧链接会回到首页并打开这一格。Skill 版本未变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a3f6288f..1cbd13f3 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8613,4 +8613,20 @@ - 复发自:无 - 修复版本:`bf8ad0d1` +## BUG-557 | 会话标题守卫比较 RPC 前快照,模型标题写库恒 0 行 + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`POST /api/consult` 首轮会话标题 +- 用户现象:新会话首轮模型起的标题,刷新、关标签或换设备后变回问题截断标题。 +- 触发条件:普通咨询新会话发第一问,在客户端 PATCH 标题之前刷新或离开。 +- 根因:`append_consultation_question` 会把空标题 / 「新对话」改成问题前 14 字。守卫却用 RPC 前快照做 `.eq("title")`,更新恒 0 行且不报错。 +- 修复:`shouldGenerateSessionTitle` 仍看 RPC 前标题。RPC 成功后再读一次当前标题做守卫。更新带 `.select("id")`,0 行 `console.warn("session title guard missed")`。 +- 验证:`frontend/tests/session-title-persist-contract.test.ts`、`application-billing-contract.test.ts`。 +- 防复发:标题守卫必须比较 RPC 后的 `chat_sessions.title`,并断言影响行数。不得用 TS 重算 SQL 的截断规则。 +- 相关记录:BUG-553 +- 复发自:BUG-553(`a1956deb` 的测试没断言守卫会命中) +- 修复版本:待发布 + diff --git a/docs/tasks/PROGRESS-session-title-guard-fix-20260906.md b/docs/tasks/PROGRESS-session-title-guard-fix-20260906.md new file mode 100644 index 00000000..db818728 --- /dev/null +++ b/docs/tasks/PROGRESS-session-title-guard-fix-20260906.md @@ -0,0 +1,41 @@ +# PROGRESS · 会话标题守卫写库(BUG-557)(2026-09-06) + +工作树:`.worktrees/session-title-guard-fix-20260906` +分支:`codex/session-title-guard-fix-20260906` +基线:`origin/staging` @ `e2d6f203`(含任务书;代码基线 `e2f4b55c`)。 + +未改迁移、未改客户端、未改 `session-title-agent.ts`、未 bump Skill。本单不加 SQL。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 5.1 守卫取 RPC 后标题 | 完成 | BUG-557 | +| 5.2 BUG_HISTORY / CHANGELOG / testing | 完成 | — | + +## 实现要点 + +- `shouldGenerateSessionTitle` 仍用 RPC 前的 `chatSession.title`,避免 ≤14 字问题被当成手改标题。 +- `append_consultation_question` 成功后再 `select("title")`。 +- 写入走 `persistGuardedSessionTitle`:`.eq("title", titleAfterRpc)` + `.select("id")`;0 行 `console.warn("session title guard missed")`。流事件仍返回 `session.title`。 + +## 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `application-billing-contract` | 只锁 consult select 含 `title` | 另锁守卫用 `titleAfterRpc` | BUG-557 | + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `npx tsx --test tests/session-title-persist-contract.test.ts tests/application-billing-contract.test.ts tests/session-title-agent.test.ts` | 17 pass / 0 fail | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | +| 改动文件 `eslint --quiet` | 0 error | + +## SQL(本单未改;清点给产品) + +相对上次有记录的 staging 部署树 `afd14948`,仓里还多出两份未随本单应用的迁移,部署前仍要跑 Gitea **Migrate Staging Database**: + +1. `20260905010000_personal_report_longform_appendices.sql`(`bab07187`,全量附录表) +2. `20260906010000_chat_session_context_summary.sql`(`bf8ad0d1`,`chat_sessions.context_summary`) + +本机无 staging 库口令,不能读 `migration.schema_migrations` 做现场核对。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 938b5e8a..1955b327 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -59,7 +59,7 @@ | `TASK-session-list-title-and-order-20260906.md` | `PROGRESS-session-list-title-and-order-20260906.md` | 历史对话标题改为首轮模型总结(一次、不扣点数、校正/今日运势保持日期标题)、侧栏去资料前缀;排序只按置顶排且元数据 PATCH 推进 `updated_at`(改为置顶 + 活动时间,改名/收藏/换模型不动顺序);历史区加 今天/昨天/7天/30天/更早 分组;列表按游标分页(每页 40、置顶首页全量、滚到底静默续取) | 已验收(1 项 P1) | `a1956deb`(BUG-553);服务端标题守卫恒不命中,修复单 `TASK-session-title-guard-fix-20260906.md` | | `TASK-settings-dialog-and-billing-pane-20260906.md` | `PROGRESS-settings-dialog-and-billing-pane-20260906.md` | 设置弹窗尺寸随分区跳变、星盘资料格无条件铺开整张添加表单(改为固定尺寸四分区、列表→详情);「账户与点数」成为弹窗分区,删除 `/membership` 与 `/membership/orders` 页面并重定向,七处入口改回调 | 已验收 | `dc6598d7`(BUG-554);真人走查欠 | | `TASK-consultation-context-and-cache-20260906.md` | `PROGRESS-consultation-context-and-cache-20260906.md` | 普通对话历史只取每条前 4,000 字(报告结论被砍、无标记)、历史窗口不看模型 `context_window`、无溢出识别;改为检查点式会话摘要 + append-only 尾巴 + 按模型预算 + 一次降级重试;Anthropic 历史断点;共享方法段进系统块;后台用量页显示缓存命中率 | 已验收 | `bf8ad0d1`(BUG-555/556);`test:db` 环境缺口;部署前先 Migrate Staging Database | -| `TASK-session-title-guard-fix-20260906.md` | `PROGRESS-session-title-guard-fix-20260906.md` | BUG-553 修复单:`consult/route.ts` 标题守卫比较的是 RPC 前快照,`append_consultation_question` 已改写标题,守卫恒 0 行,模型标题只靠客户端 PATCH 落库 | 待执行 | `codex/session-title-guard-fix-20260906`(BUG-557) | +| `TASK-session-title-guard-fix-20260906.md` | `PROGRESS-session-title-guard-fix-20260906.md` | BUG-553 修复单:`consult/route.ts` 标题守卫比较的是 RPC 前快照,`append_consultation_question` 已改写标题,守卫恒 0 行,模型标题只靠客户端 PATCH 落库 | 待验收 | `codex/session-title-guard-fix-20260906`(BUG-557) | | `TASK-rectification-collect-vs-offer-consistency-20260905.md` | `PROGRESS-rectification-collect-vs-offer-consistency-20260905.md` | 带年份采集没问完就出采用卡 + 报告,同一轮又被搬家采集题把卡挤掉:决策层判 `adopt_representative` 而计划层仍有 dated 采集(BUG-546 只修了一半);改为剩余采集未完保持 `collect_evidence`,出牌轮才出卡写报告 | 已验收通过 `ca4e2408`(2026-09-05;staging 部署仍停在 `afd14948`,`deploy-staging` 自 `c295b853` 起连续失败,先解决 `bab07187` 的待迁移) | `codex/rectification-collect-vs-offer-consistency-20260905`(BUG-550) | | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/session-list-title-and-order-20260906.md b/docs/testing/session-list-title-and-order-20260906.md index ac47342f..9920036c 100644 --- a/docs/testing/session-list-title-and-order-20260906.md +++ b/docs/testing/session-list-title-and-order-20260906.md @@ -17,6 +17,14 @@ - ✅ 预期:标题变成大约 6–12 字的主题,例如「半年内换工作时机」这类总结,而不是第一句话截断加省略号。本人资料时侧栏没有「资料名 · 」前缀。 - ❌ 失败:一直停在「新对话」或「我想问一下…」;或标题里出现出生年月日、钟点;或界面出现「正在生成标题」之类提示。 +## 1b. 生成中刷新后标题仍是模型总结(P0,BUG-557) + +1. 新开一条普通咨询,发出一句虚构问题。 +2. 回答还在生成时刷新页面(或换一个浏览器标签打开同一会话)。 + +- ✅ 预期:侧栏标题仍是模型总结,不是问题截断加省略号。 +- ❌ 失败:刷新后变回「我想问一下最近半年换工…」这类截断句。 + ## 2. 校正和今日节奏标题不变(P0) 1. 打开生时校正会话。 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 64e125d7..4acb0b8a 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -83,6 +83,7 @@ import { checkpointSessionContextSummary, generateSessionContextSummaryText, } from "@/lib/session-context-summary"; +import { persistGuardedSessionTitle } from "@/lib/session-title-guard"; import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent"; import { z } from "zod"; @@ -565,6 +566,14 @@ export async function POST(request: Request) { ); } + const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : ""; + const { data: titleRow } = await supabase + .from("chat_sessions") + .select("title") + .eq("id", sessionId) + .eq("user_id", userId) + .maybeSingle(); + const usageStartedAt = Date.now(); async function checkpointConsultationContext() { try { @@ -690,7 +699,7 @@ export async function POST(request: Request) { // its settle-and-log entry point here so the request-level catch below can // still emit it. const agenticFailure: { report?: (error: unknown) => Promise } = {}; - const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : ""; + const titleAfterRpc = typeof titleRow?.title === "string" ? titleRow.title : expectedTitle; const titleSideEvent = shouldGenerateSessionTitle({ title: expectedTitle, sessionType: chatSession.session_type, @@ -703,15 +712,13 @@ export async function POST(request: Request) { signal: request.signal, }).then(async (title) => { if (!title) return null; - try { - const { error } = await supabase.from("chat_sessions").update({ title }) - .eq("id", sessionId) - .eq("user_id", userId) - .eq("title", expectedTitle); - if (error) console.warn("session title persist failed", error); - } catch (error) { - console.warn("session title persist failed", error); - } + await persistGuardedSessionTitle({ + client: supabase, + sessionId, + userId, + expectedTitle: titleAfterRpc, + title, + }); return { type: "session.title" as const, title }; }).catch((error) => { console.warn("session title failed", error); diff --git a/frontend/src/lib/session-title-guard.ts b/frontend/src/lib/session-title-guard.ts new file mode 100644 index 00000000..fd4da98b --- /dev/null +++ b/frontend/src/lib/session-title-guard.ts @@ -0,0 +1,40 @@ +type GuardedTitleUpdate = { + eq: (column: string, value: string) => GuardedTitleUpdate; + select: (columns: "id") => PromiseLike<{ data: Array<{ id?: string }> | null; error: unknown }>; +}; + +export type GuardedTitleClient = { + from: (table: "chat_sessions") => { + update: (values: { title: string }) => GuardedTitleUpdate; + }; +}; + +export async function persistGuardedSessionTitle(input: { + client: GuardedTitleClient; + sessionId: string; + userId: string; + expectedTitle: string; + title: string; + warn?: (message: string, extra?: unknown) => void; +}): Promise<"updated" | "missed" | "failed"> { + const warn = input.warn ?? ((message: string, extra?: unknown) => console.warn(message, extra)); + try { + const { data, error } = await input.client.from("chat_sessions").update({ title: input.title }) + .eq("id", input.sessionId) + .eq("user_id", input.userId) + .eq("title", input.expectedTitle) + .select("id"); + if (error) { + warn("session title persist failed", error); + return "failed"; + } + if (!data?.length) { + warn("session title guard missed"); + return "missed"; + } + return "updated"; + } catch (error) { + warn("session title persist failed", error); + return "failed"; + } +} diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index 46a787aa..a30486ba 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -77,6 +77,13 @@ test("standard consultation resolves and settles the session-pinned model versio assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); assert.match(consultRoute, /actualModelId: selectedModel\.id/); assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/); + // 原值: 守卫 .eq("title", expectedTitle) 用 RPC 前快照,append_consultation_question 已改写标题,更新恒 0 行 + // 新值: shouldGenerate 仍用 RPC 前标题;守卫比较 RPC 后再 select 到的 title + // 原因: BUG-557,模型标题必须能在刷新前落库 + assert.match(consultRoute, /const expectedTitle = typeof chatSession\.title === "string" \? chatSession\.title : ""/); + assert.match(consultRoute, /shouldGenerateSessionTitle\(\{\s*title: expectedTitle,/); + assert.match(consultRoute, /expectedTitle: titleAfterRpc/); + assert.match(consultRoute, /persistGuardedSessionTitle/); }); test("standard consultation awaits real usage before durable response settlement", () => { diff --git a/frontend/tests/session-title-persist-contract.test.ts b/frontend/tests/session-title-persist-contract.test.ts new file mode 100644 index 00000000..15d50831 --- /dev/null +++ b/frontend/tests/session-title-persist-contract.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { persistGuardedSessionTitle, type GuardedTitleClient } from "../src/lib/session-title-guard.ts"; + +const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); + +function sourceBetween(source: string, start: string, end: string): string { + const startIndex = source.indexOf(start); + const endIndex = source.indexOf(end, startIndex + start.length); + assert.ok(startIndex >= 0, `missing start marker: ${start}`); + assert.ok(endIndex > startIndex, `missing end marker: ${end}`); + return source.slice(startIndex, endIndex); +} + +function mockClient(options: { + rows?: Array<{ id: string }>; + error?: unknown; + throwOnSelect?: unknown; +}): { client: GuardedTitleClient; titleEq: string[] } { + const titleEq: string[] = []; + const chain = { + eq(column: string, value: string) { + if (column === "title") titleEq.push(value); + return chain; + }, + select() { + if (options.throwOnSelect) return Promise.reject(options.throwOnSelect); + return Promise.resolve({ + data: options.error ? null : (options.rows ?? []), + error: options.error ?? null, + }); + }, + }; + return { + titleEq, + client: { + from(table) { + assert.equal(table, "chat_sessions"); + return { + update(values) { + assert.equal(typeof values.title, "string"); + return chain; + }, + }; + }, + }, + }; +} + +test("consult generates a title from the pre-RPC snapshot and guards with the post-RPC title", () => { + const afterAppend = sourceBetween( + consultRoute, + "if (!appendedQuestion.success) {", + "const usageStartedAt = Date.now();", + ); + const persist = sourceBetween( + consultRoute, + "const titleAfterRpc =", + "async function runAgenticConsultation", + ); + + assert.match(afterAppend, /const expectedTitle = typeof chatSession\.title === "string" \? chatSession\.title : ""/); + assert.match(afterAppend, /\.select\("title"\)[\s\S]*\.eq\("id", sessionId\)[\s\S]*\.eq\("user_id", userId\)[\s\S]*\.maybeSingle\(\)/); + assert.match( + persist, + /shouldGenerateSessionTitle\(\{\s*title: expectedTitle,/, + ); + assert.match(persist, /expectedTitle: titleAfterRpc/); + assert.match(persist, /persistGuardedSessionTitle/); + assert.match(persist, /return \{ type: "session\.title" as const, title \}/); + assert.doesNotMatch(persist, /\.eq\("title", expectedTitle\)/); +}); + +test("the title guard writes when the current title still matches the post-RPC value", async () => { + const { client, titleEq } = mockClient({ rows: [{ id: "session-1" }] }); + const warnings: string[] = []; + + const result = await persistGuardedSessionTitle({ + client, + sessionId: "session-1", + userId: "user-1", + expectedTitle: "半年内换工作时机…", + title: "换工作窗口", + warn: (message) => warnings.push(message), + }); + + assert.equal(result, "updated"); + assert.deepEqual(titleEq, ["半年内换工作时机…"]); + assert.deepEqual(warnings, []); +}); + +test("the title guard misses and warns when the user renamed the session", async () => { + const { client, titleEq } = mockClient({ rows: [] }); + const warnings: string[] = []; + + const result = await persistGuardedSessionTitle({ + client, + sessionId: "session-1", + userId: "user-1", + expectedTitle: "半年内换工作时机…", + title: "换工作窗口", + warn: (message) => warnings.push(message), + }); + + assert.equal(result, "missed"); + assert.deepEqual(titleEq, ["半年内换工作时机…"]); + assert.deepEqual(warnings, ["session title guard missed"]); +});