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:
Jesse_Chen
2026-09-06 19:22:43 +08:00
co-authored by Cursor
parent e2d6f20361
commit 0102a973a3
9 changed files with 243 additions and 11 deletions
+17 -10
View File
@@ -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<void> } = {};
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);
+40
View File
@@ -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, /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", () => {
@@ -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"]);
});