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:
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user