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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# 印度占星 Skill 更新日志
|
# 印度占星 Skill 更新日志
|
||||||
|
|
||||||
|
## 2026-09-06 — 首轮咨询标题在刷新前就会落库
|
||||||
|
|
||||||
|
新开的普通咨询,模型起的主题标题会写进服务器。生成中途刷新或换设备,侧栏仍是那句总结,而不是问题前几个字加省略号。你中途自己改名的,仍以你改的为准。Skill 版本未变。
|
||||||
|
|
||||||
## 2026-09-06 — 设置弹窗固定尺寸,账户与点数进弹窗
|
## 2026-09-06 — 设置弹窗固定尺寸,账户与点数进弹窗
|
||||||
|
|
||||||
设置弹窗四个分区共用一个外框,切换时不再跳变。星盘资料先出列表,点进去才编辑或添加。账户与点数成为第四个分区,套餐、点数包、兑换码和订单都在里面完成;原来的 `/membership` 页面删除,旧链接会回到首页并打开这一格。Skill 版本未变。
|
设置弹窗四个分区共用一个外框,切换时不再跳变。星盘资料先出列表,点进去才编辑或添加。账户与点数成为第四个分区,套餐、点数包、兑换码和订单都在里面完成;原来的 `/membership` 页面删除,旧链接会回到首页并打开这一格。Skill 版本未变。
|
||||||
|
|||||||
@@ -8613,4 +8613,20 @@
|
|||||||
- 复发自:无
|
- 复发自:无
|
||||||
- 修复版本:`bf8ad0d1`
|
- 修复版本:`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` 的测试没断言守卫会命中)
|
||||||
|
- 修复版本:待发布
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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` 做现场核对。
|
||||||
@@ -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-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-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-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-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-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) |
|
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) |
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
- ✅ 预期:标题变成大约 6–12 字的主题,例如「半年内换工作时机」这类总结,而不是第一句话截断加省略号。本人资料时侧栏没有「资料名 · 」前缀。
|
- ✅ 预期:标题变成大约 6–12 字的主题,例如「半年内换工作时机」这类总结,而不是第一句话截断加省略号。本人资料时侧栏没有「资料名 · 」前缀。
|
||||||
- ❌ 失败:一直停在「新对话」或「我想问一下…」;或标题里出现出生年月日、钟点;或界面出现「正在生成标题」之类提示。
|
- ❌ 失败:一直停在「新对话」或「我想问一下…」;或标题里出现出生年月日、钟点;或界面出现「正在生成标题」之类提示。
|
||||||
|
|
||||||
|
## 1b. 生成中刷新后标题仍是模型总结(P0,BUG-557)
|
||||||
|
|
||||||
|
1. 新开一条普通咨询,发出一句虚构问题。
|
||||||
|
2. 回答还在生成时刷新页面(或换一个浏览器标签打开同一会话)。
|
||||||
|
|
||||||
|
- ✅ 预期:侧栏标题仍是模型总结,不是问题截断加省略号。
|
||||||
|
- ❌ 失败:刷新后变回「我想问一下最近半年换工…」这类截断句。
|
||||||
|
|
||||||
## 2. 校正和今日节奏标题不变(P0)
|
## 2. 校正和今日节奏标题不变(P0)
|
||||||
|
|
||||||
1. 打开生时校正会话。
|
1. 打开生时校正会话。
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import {
|
|||||||
checkpointSessionContextSummary,
|
checkpointSessionContextSummary,
|
||||||
generateSessionContextSummaryText,
|
generateSessionContextSummaryText,
|
||||||
} from "@/lib/session-context-summary";
|
} from "@/lib/session-context-summary";
|
||||||
|
import { persistGuardedSessionTitle } from "@/lib/session-title-guard";
|
||||||
import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent";
|
import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent";
|
||||||
import { z } from "zod";
|
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();
|
const usageStartedAt = Date.now();
|
||||||
async function checkpointConsultationContext() {
|
async function checkpointConsultationContext() {
|
||||||
try {
|
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
|
// its settle-and-log entry point here so the request-level catch below can
|
||||||
// still emit it.
|
// still emit it.
|
||||||
const agenticFailure: { report?: (error: unknown) => Promise<void> } = {};
|
const agenticFailure: { report?: (error: unknown) => Promise<void> } = {};
|
||||||
const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : "";
|
const titleAfterRpc = typeof titleRow?.title === "string" ? titleRow.title : expectedTitle;
|
||||||
const titleSideEvent = shouldGenerateSessionTitle({
|
const titleSideEvent = shouldGenerateSessionTitle({
|
||||||
title: expectedTitle,
|
title: expectedTitle,
|
||||||
sessionType: chatSession.session_type,
|
sessionType: chatSession.session_type,
|
||||||
@@ -703,15 +712,13 @@ export async function POST(request: Request) {
|
|||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
}).then(async (title) => {
|
}).then(async (title) => {
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
try {
|
await persistGuardedSessionTitle({
|
||||||
const { error } = await supabase.from("chat_sessions").update({ title })
|
client: supabase,
|
||||||
.eq("id", sessionId)
|
sessionId,
|
||||||
.eq("user_id", userId)
|
userId,
|
||||||
.eq("title", expectedTitle);
|
expectedTitle: titleAfterRpc,
|
||||||
if (error) console.warn("session title persist failed", error);
|
title,
|
||||||
} catch (error) {
|
});
|
||||||
console.warn("session title persist failed", error);
|
|
||||||
}
|
|
||||||
return { type: "session.title" as const, title };
|
return { type: "session.title" as const, title };
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.warn("session title failed", error);
|
console.warn("session title failed", error);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
|
||||||
assert.match(consultRoute, /actualModelId: selectedModel\.id/);
|
assert.match(consultRoute, /actualModelId: selectedModel\.id/);
|
||||||
assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/);
|
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", () => {
|
test("standard consultation awaits real usage before durable response settlement", () => {
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user