fix(web): keep rectification sessions listed and persist chats on first send
Empty-consultation filtering is now session_type scoped so birth-time rows stay in the sidebar. Subtitles use updatedAt with sort/group/cursor. New chats stay local until the first send.
This commit is contained in:
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
|
||||
import { homeSurface as page } from "./home-surface.ts";
|
||||
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
|
||||
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
||||
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const sql = readFileSync(
|
||||
@@ -12,26 +13,35 @@ const sql = readFileSync(
|
||||
);
|
||||
const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current"));
|
||||
|
||||
test("session list GET drops empty consultations and still returns a reusable draft", () => {
|
||||
assert.match(listRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.match(listRoute, /\.eq\("session_type", "consultation"\)[\s\S]*\.eq\("messages", \[\]\)/);
|
||||
assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor, draft \}\)/);
|
||||
test("session list GET drops empty consultations without swallowing rectification rows", () => {
|
||||
// 原值:.not("messages", "eq", []) 全表过滤,响应带 draft
|
||||
// 新值:or(session_type.neq.consultation,messages.neq.[]),响应只有 sessions/nextCursor
|
||||
// 原因:校正会话 messages 永远是 [](BUG-987);第一问前不落库后不再返回 draft(BUG-989)
|
||||
assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/);
|
||||
assert.match(listRoute, /excludeEmptyConsultations/);
|
||||
assert.match(listRoute, /session_type/);
|
||||
assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor \}\)/);
|
||||
assert.doesNotMatch(listRoute, /draft/);
|
||||
assert.doesNotMatch(itemRoute, /\.or\("session_type\.neq\.consultation,messages\.neq\.\[\]"\)/);
|
||||
assert.doesNotMatch(itemRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
});
|
||||
|
||||
test("session list GET omits messages while detail GET returns them", () => {
|
||||
// Former list/detail column strings ended at updated_at; pinned and archived_at
|
||||
// were added when those flags moved off localStorage.
|
||||
// 原值:列表列到 updated_at,pinned,archived_at,不选 created_at
|
||||
// 新值:加上 created_at,供侧栏副标题用会话创建时间
|
||||
// 原因:任务书 T4 副标题是 M月D日 HH:MM(创建时间,Asia/Shanghai)
|
||||
// 原值:列表列含 created_at,供副标题用创建时间
|
||||
// 新值:SESSION_LIST_COLUMNS 不含 created_at
|
||||
// 原因:副标题改最后活动时间,与排序/分组/游标同源(BUG-988)
|
||||
assert.match(
|
||||
listRoute,
|
||||
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/,
|
||||
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
|
||||
);
|
||||
// 原值:select(SESSION_LIST_COLUMNS) 之后不得出现 messages
|
||||
// 新值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
|
||||
// 原因:T3 空咨询过滤要看 messages,但不把正文带回列表
|
||||
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*created_at/);
|
||||
// 原值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
|
||||
// 新值:WHERE 用 session_type 限定的 or(),SELECT 列仍不含 messages
|
||||
// 原因:T1 空咨询过滤要看 messages,但不把正文带回列表
|
||||
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*messages/);
|
||||
assert.doesNotMatch(
|
||||
listRoute,
|
||||
|
||||
@@ -174,8 +174,14 @@ test("popstate to a missing session query reuses selectSession side effects for
|
||||
|
||||
test("creating and leaving a session keep the address bar in sync", () => {
|
||||
const startNewChat = sourceBetween(page, "async function startNewChat(", "function selectSession(");
|
||||
assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
|
||||
assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/);
|
||||
// 原值:新建立刻 writeSessionUrl(nextSession.id, "push"),失败再 replaceState 回旧地址
|
||||
// 新值:本地创建不写 ?c=;POST 成功后 persistSession(create) 才 push
|
||||
// 原因:第一问前不落库(BUG-989),未落库会话没有服务端身份
|
||||
assert.doesNotMatch(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
|
||||
assert.doesNotMatch(startNewChat, /persistSession\(/);
|
||||
const persistSession = sourceBetween(page, "async function persistSession(", "async function ensureSessionMessages(");
|
||||
assert.match(persistSession, /if \(mode === "create"\) \{/);
|
||||
assert.match(persistSession, /writeSessionUrl\(session\.id, "push"\)/);
|
||||
|
||||
// 原值:delete/archive 函数体内直接 `writeSessionUrl(fallbackId || null, "replace")`
|
||||
// 新值:删/归档当前会话走 `activateFallbackSession`,由它 `writeSessionUrl(null|fallbackId, "replace")`
|
||||
|
||||
@@ -313,7 +313,11 @@ test("self-hosted staging bootstrap reads profile and sessions through same-orig
|
||||
assert.doesNotMatch(page, /createBrowserSupabaseClient/);
|
||||
assert.match(page, /fetch\("\/api\/account"/);
|
||||
assert.match(page, /fetch\("\/api\/sessions"/);
|
||||
assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
|
||||
// 原值:启动时空列表立刻 writeChatSession(initialSession.id, ..., "create")
|
||||
// 新值:本地 createSession,第一问 send() 才 persistSession(..., "create")
|
||||
// 原因:BUG-989 第一问之前不落库
|
||||
assert.doesNotMatch(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
|
||||
assert.match(page, /await persistSession\(currentSession, "create"\)/);
|
||||
assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/);
|
||||
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
|
||||
assert.match(accountRoute, /profile,/);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
createLocalPostgresDataClient,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { applyArchiveFilter, excludeEmptyConsultations } from "../src/lib/session-list-filter.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
const emptyConsultation = "11111111-1111-4111-8111-111111111111";
|
||||
const emptyRectification = "22222222-2222-4222-8222-222222222222";
|
||||
const filledConsultation = "33333333-3333-4333-8333-333333333333";
|
||||
const archivedRectification = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
test("session list query keeps empty rectification rows and drops empty consultations", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runner], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
|
||||
},
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`insert into identity.users(name,email,email_verified,email_verified_at) values ('Fictional List','list-visibility@example.com',true,now());`,
|
||||
);
|
||||
const userId = fixture.psql("select id from identity.users where email='list-visibility@example.com'");
|
||||
fixture.psql(`
|
||||
insert into public.chat_sessions (id, user_id, title, theme, model_id, messages, session_type, pinned, archived_at, updated_at)
|
||||
values
|
||||
('${emptyConsultation}', '${userId}', 'Empty consult', 'general', 'test-model', '[]', 'consultation', false, null, now()),
|
||||
('${emptyRectification}', '${userId}', 'Empty rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, null, now()),
|
||||
('${filledConsultation}', '${userId}', 'Filled consult', 'general', 'test-model', '[{"role":"user","text":"问一句"}]', 'consultation', false, null, now()),
|
||||
('${archivedRectification}', '${userId}', 'Archived rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, '2026-09-08T00:00:00Z', now());
|
||||
`);
|
||||
const local = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
|
||||
{ id: userId, email: "list-visibility@example.com" },
|
||||
);
|
||||
const live = await excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false),
|
||||
false,
|
||||
),
|
||||
);
|
||||
assert.equal(live.error, null, live.error?.message);
|
||||
const liveIds = (live.data as { id: string }[]).map((row) => row.id).sort();
|
||||
assert.deepEqual(liveIds, [emptyRectification, filledConsultation].sort());
|
||||
const archived = await excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false),
|
||||
true,
|
||||
),
|
||||
);
|
||||
assert.equal(archived.error, null, archived.error?.message);
|
||||
assert.deepEqual(
|
||||
(archived.data as { id: string }[]).map((row) => row.id),
|
||||
[archivedRectification],
|
||||
);
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { sessionCursorFilter } from "../src/lib/session-cursor.ts";
|
||||
|
||||
const core = readFileSync(new URL("../src/lib/db/local-postgres-client-core.ts", import.meta.url), "utf8");
|
||||
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
|
||||
|
||||
test("or() compiles a comma-separated PostgREST filter", () => {
|
||||
const parameters: unknown[] = [];
|
||||
@@ -48,3 +49,10 @@ test("or() compiles the session cursor filter with a nested and", () => {
|
||||
]);
|
||||
assert.match(listRoute, /pageQuery = pageQuery\.or\(sessionCursorFilter\(cursor\)\)/);
|
||||
});
|
||||
|
||||
test("session list empty-consultation filter is session_type scoped", () => {
|
||||
assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/);
|
||||
assert.match(listRoute, /excludeEmptyConsultations/);
|
||||
assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
recencyKeyFor,
|
||||
sortSessions,
|
||||
} from "../src/lib/session-groups.ts";
|
||||
import { sessionSidebarSubtitle } from "../src/lib/session-sidebar-row.ts";
|
||||
import { shanghaiDateTimeLabel } from "../src/lib/session-shanghai-clock.ts";
|
||||
import type { ChatSession } from "../src/lib/home-types.ts";
|
||||
|
||||
function session(id: string, updatedAt: number, pinned = false) {
|
||||
return { id, pinned, updatedAt };
|
||||
@@ -72,6 +75,44 @@ test("mergeSessionPage keeps the local row and skips a duplicate id", () => {
|
||||
assert.equal(mergeSessionPage([local], incoming)[0]?.updatedAt, 900);
|
||||
});
|
||||
|
||||
test("sidebar subtitle follows updatedAt so the visible clock matches sortSessions", () => {
|
||||
const createdEarly = Date.parse("2026-09-07T05:42:00.000Z");
|
||||
const activeLate = Date.parse("2026-09-16T10:01:00.000Z");
|
||||
const mid = Date.parse("2026-09-17T14:53:00.000Z");
|
||||
const newest = Date.parse("2026-09-18T00:01:00.000Z");
|
||||
function row(id: string, createdAt: number, updatedAt: number): ChatSession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
theme: "general",
|
||||
modelId: "m",
|
||||
messages: [{ role: "user", text: "问一句" }],
|
||||
createdAt,
|
||||
updatedAt,
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: null,
|
||||
chartProfileName: null,
|
||||
chartProfileRole: null,
|
||||
pinned: false,
|
||||
archivedAt: null,
|
||||
messagesHydrated: true,
|
||||
};
|
||||
}
|
||||
const earlyCreatedLateActive = row("early-created", createdEarly, activeLate);
|
||||
const sessions = [
|
||||
earlyCreatedLateActive,
|
||||
row("mid", mid, mid),
|
||||
row("newest", newest, newest),
|
||||
];
|
||||
assert.deepEqual(sortSessions(sessions).map((item) => item.id), ["newest", "mid", "early-created"]);
|
||||
const clocks = sortSessions(sessions).map((item) => sessionSidebarSubtitle(item));
|
||||
const expected = sortSessions(sessions).map((item) => shanghaiDateTimeLabel(new Date(item.updatedAt)));
|
||||
assert.deepEqual(clocks, expected);
|
||||
assert.equal(sessionSidebarSubtitle(earlyCreatedLateActive), shanghaiDateTimeLabel(new Date(activeLate)));
|
||||
assert.notEqual(sessionSidebarSubtitle(earlyCreatedLateActive), shanghaiDateTimeLabel(new Date(createdEarly)));
|
||||
});
|
||||
|
||||
test("beginSessionPageLoad only starts one in-flight request", () => {
|
||||
const inFlight = { current: false };
|
||||
assert.equal(beginSessionPageLoad(inFlight, "cursor"), true);
|
||||
|
||||
@@ -3,13 +3,19 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
findReusableEmptyConsultation,
|
||||
cloudListIncludesSession,
|
||||
EMPTY_CONSULTATION_LIST_FILTER,
|
||||
isListedSidebarSession,
|
||||
isUnsavedEmptyConsultation,
|
||||
replaceUnsavedEmptyConsultations,
|
||||
} from "../src/lib/session-list-filter.ts";
|
||||
import type { ChatSession } from "../src/lib/home-types.ts";
|
||||
|
||||
const management = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const listContext = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
|
||||
const consultRun = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
|
||||
|
||||
function session(change: Partial<ChatSession>): ChatSession {
|
||||
return {
|
||||
@@ -31,7 +37,7 @@ function session(change: Partial<ChatSession>): ChatSession {
|
||||
};
|
||||
}
|
||||
|
||||
test("an empty hydrated consultation can be reused instead of creating another", () => {
|
||||
test("empty consultations stay off the sidebar while rectification rows stay on it", () => {
|
||||
const empty = session({ id: "empty" });
|
||||
const listed = session({
|
||||
id: "listed",
|
||||
@@ -43,11 +49,71 @@ test("an empty hydrated consultation can be reused instead of creating another",
|
||||
sessionType: "birth_time_rectification",
|
||||
title: "生时校正 · 9月14日",
|
||||
});
|
||||
assert.equal(findReusableEmptyConsultation([listed, empty, rectification])?.id, "empty");
|
||||
assert.equal(findReusableEmptyConsultation([listed, rectification]), undefined);
|
||||
const archivedRectification = session({
|
||||
id: "archived-rect",
|
||||
sessionType: "birth_time_rectification",
|
||||
archivedAt: "2026-09-08T00:00:00.000Z",
|
||||
});
|
||||
assert.equal(isListedSidebarSession(empty), false);
|
||||
assert.equal(isListedSidebarSession(listed), true);
|
||||
assert.equal(isListedSidebarSession(rectification), true);
|
||||
assert.match(management, /findReusableEmptyConsultation\(sessions\)/);
|
||||
assert.match(page, /readDraftConsultation\(listBoot\.draftRow/);
|
||||
assert.equal(isListedSidebarSession(archivedRectification), false);
|
||||
assert.equal(isUnsavedEmptyConsultation(empty), true);
|
||||
assert.equal(isUnsavedEmptyConsultation(rectification), false);
|
||||
});
|
||||
|
||||
test("server list filter and sidebar listing agree on empty consultation, rectification, and archive", () => {
|
||||
const cases = [
|
||||
{ sessionType: "consultation" as const, messagesEmpty: true, archived: false, archivedView: false, listed: false },
|
||||
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: false, archivedView: false, listed: true },
|
||||
{ sessionType: "consultation" as const, messagesEmpty: false, archived: false, archivedView: false, listed: true },
|
||||
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, archivedView: false, listed: false },
|
||||
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, archivedView: true, listed: true },
|
||||
{ sessionType: "consultation" as const, messagesEmpty: true, archived: true, archivedView: true, listed: false },
|
||||
];
|
||||
for (const row of cases) {
|
||||
const cloud = cloudListIncludesSession(row);
|
||||
const client = isListedSidebarSession(session({
|
||||
sessionType: row.sessionType,
|
||||
messages: row.messagesEmpty ? [] : [{ role: "user", text: "问一句" }],
|
||||
archivedAt: row.archived ? "2026-09-08T00:00:00.000Z" : null,
|
||||
messagesHydrated: true,
|
||||
}));
|
||||
assert.equal(cloud, row.listed, JSON.stringify(row));
|
||||
if (!row.archivedView) assert.equal(client, row.listed, JSON.stringify(row));
|
||||
}
|
||||
assert.equal(EMPTY_CONSULTATION_LIST_FILTER, "session_type.neq.consultation,messages.neq.[]");
|
||||
assert.match(listRoute, /excludeEmptyConsultations/);
|
||||
assert.match(listRoute, /from "@\/lib\/session-list-filter"/);
|
||||
});
|
||||
|
||||
test("new chat stays local until the first send and does not reuse a draft row", () => {
|
||||
const empty = session({ id: "empty" });
|
||||
const listed = session({
|
||||
id: "listed",
|
||||
messages: [{ role: "user", text: "问一句" }],
|
||||
title: "半年内换工作时机",
|
||||
});
|
||||
const next = session({ id: "next" });
|
||||
assert.deepEqual(
|
||||
replaceUnsavedEmptyConsultations([listed, empty], next).map((item) => item.id),
|
||||
["next", "listed"],
|
||||
);
|
||||
assert.doesNotMatch(management, /findReusableEmptyConsultation/);
|
||||
assert.doesNotMatch(page, /readDraftConsultation|findReusableEmptyConsultation|writeChatSession/);
|
||||
assert.doesNotMatch(listContext, /draftRow/);
|
||||
assert.match(management, /replaceUnsavedEmptyConsultations\(current, nextSession\)/);
|
||||
assert.doesNotMatch(management, /writeSessionUrl\(nextSession\.id, "push"\)/);
|
||||
assert.match(consultRun, /isUnsavedEmptyConsultation\(currentSession\)/);
|
||||
assert.match(consultRun, /await persistSession\(currentSession, "create"\)/);
|
||||
assert.match(management, /if \(mode === "create"\) \{/);
|
||||
assert.match(management, /cloudCreatedIds\.current\.add\(session\.id\)/);
|
||||
assert.match(management, /writeSessionUrl\(session\.id, "push"\)/);
|
||||
const persistBlockStart = consultRun.indexOf("if (isUnsavedEmptyConsultation(currentSession))");
|
||||
const persistBlockEnd = consultRun.indexOf("const [year, month, day]");
|
||||
assert.ok(persistBlockStart >= 0 && persistBlockEnd > persistBlockStart);
|
||||
const persistBlock = consultRun.slice(persistBlockStart, persistBlockEnd);
|
||||
assert.match(persistBlock, /setSessions\(remaining\)/);
|
||||
assert.match(persistBlock, /setRequestError/);
|
||||
assert.doesNotMatch(persistBlock, /setDraft\(/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user