fix(consult): drop traces, budget checkpoints, silent summary inherit
Independent Staging Quality Gate / validate (push) Canceled after 3m8s
Independent Staging Quality Gate / publish (push) Canceled after 0s

BUG-729: dropped history rounds leave an omission marker in the model-visible summary slot.
BUG-730: checkpoint threshold is 0.4 of historyBudgetChars (128k still 16,000).
BUG-731: session_full new chat copies owned context_summary on the server; clients send only continued_from_session_id.
This commit is contained in:
jesse-ux
2026-09-16 07:38:36 +08:00
parent e61535f464
commit 149e1ec4c3
18 changed files with 451 additions and 31 deletions
@@ -20,6 +20,10 @@ test("session list GET omits messages while detail GET returns them", () => {
/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"/,
);
assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/);
assert.doesNotMatch(
listRoute,
/SESSION_LIST_COLUMNS = "[^"]*context_summary/,
);
assert.match(itemRoute, /export async function GET/);
assert.match(
itemRoute,
@@ -52,6 +56,8 @@ test("consult appends the user question after reserve and returns session_full",
assert.match(sendSource, /caught\.code === "session_full"/);
assert.match(sendSource, /label: "开新对话"/);
assert.match(sendSource, /continueInNewChat\(\{ question: originalQuestion, theme \}\)/);
assert.match(page, /continuedFromSessionId: sourceSessionId/);
assert.doesNotMatch(page, /接着上次|继续上次聊|继承会话摘要|从上次对话继续/);
});
test("PATCH compatibility accepts and ignores a legacy messages write", () => {
+1 -1
View File
@@ -173,7 +173,7 @@ 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(");
const startNewChat = sourceBetween(page, "async function startNewChat(", "function selectSession(");
assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/);
+87 -3
View File
@@ -1,7 +1,14 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
import {
chatSessionCreateInsertRow,
chatSessionCreateSchema,
chatSessionMetadataPatchSchema,
chatSessionWriteSchema,
writeChatSession,
type ChatSessionWrite,
} from "../src/lib/chat-session-write-contract.ts";
import { metadataUpdateValues } from "../src/lib/session-metadata-update.ts";
import { homeSurface } from "./home-surface.ts";
@@ -41,6 +48,61 @@ test("create schema keeps the client-generated session id after transcript limit
assert.equal(parsed.messages.length, 0);
});
test("create schema accepts continued_from_session_id and rejects client-supplied context_summary", () => {
const parsed = chatSessionCreateSchema.parse({
id: sessionId,
...createValues,
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
});
assert.equal(parsed.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in parsed, false);
assert.equal("context_summary" in chatSessionCreateSchema.shape, false);
const withSummary = chatSessionCreateSchema.safeParse({
id: sessionId,
...createValues,
context_summary: { version: 1, text: "injected" },
});
assert.equal(withSummary.success, false);
const badId = chatSessionCreateSchema.safeParse({
id: sessionId,
...createValues,
continued_from_session_id: "not-a-uuid",
});
assert.equal(badId.success, false);
});
test("create insert copies an owned summary and omits a missing one", () => {
const source = {
version: 1 as const,
text: "已问过的问题\n事业时机",
throughRequestId: "a1",
throughMessageIndex: 3,
messageCount: 4,
updatedAt: "2026-09-15T00:00:00.000Z",
};
const copied = chatSessionCreateInsertRow({
id: sessionId,
userId: "owner",
values: createValues,
inheritedSummary: source,
updatedAt: "2026-09-16T00:00:00.000Z",
});
assert.deepEqual(copied.context_summary, source);
assert.equal("continued_from_session_id" in copied, false);
const skipped = chatSessionCreateInsertRow({
id: sessionId,
userId: "owner",
values: createValues,
inheritedSummary: null,
updatedAt: "2026-09-16T00:00:00.000Z",
});
assert.equal("context_summary" in skipped, false);
assert.equal(skipped.user_id, "owner");
});
test("create schema rejects a non-empty transcript", () => {
// Former value: create accepted up to CHAT_SESSION_MAX_MESSAGES and was a
// history-import path. Create is now only an empty session.
@@ -122,6 +184,19 @@ test("metadata patch accepts pin and archive fields without a transcript", () =>
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ archived_at: null }), { archived_at: null });
});
test("create write may carry continued_from_session_id and never context_summary", async () => {
const calls: Array<Record<string, unknown>> = [];
await writeChatSession(sessionId, {
...createValues,
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
}, "create", async (_url, init) => {
calls.push(JSON.parse(String(init?.body)));
return Response.json({ ok: true }, { status: 201 });
});
assert.equal(calls[0]?.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in (calls[0] ?? {}), false);
});
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => {
@@ -205,8 +280,17 @@ test("session API owns create and update while answer UI keeps sync failures out
assert.match(itemRoute, /readChatSessionJson/);
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
// Former value: `const { id, ...values } = parsed.data` trusted the client clock.
assert.match(collectionRoute, /const \{ id, updated_at: _ignoredClientClock, \.\.\.values \} = parsed\.data/);
// Former value: `const { id, updated_at: _ignoredClientClock, ...values } = parsed.data`
// New value: also peel `continued_from_session_id` so it is never inserted as a column.
assert.match(
collectionRoute,
/const \{ id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, \.\.\.values \} = parsed\.data/,
);
assert.match(collectionRoute, /resolveInheritedContextSummary/);
assert.match(collectionRoute, /chatSessionCreateInsertRow/);
assert.match(collectionRoute, /\.eq\("user_id", user\.id\)/);
assert.match(collectionRoute, /status: 201/);
assert.doesNotMatch(collectionRoute, /context_summary: parsed/);
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string; thinkingSections\?: unknown \}> \}>/);
assert.match(contract, /\): z\.ZodType<Output> \{/);
assert.match(page, /chartSnapshotForSession/);
@@ -91,7 +91,7 @@ test("every external draft writer keeps working through the page-owned setters",
// When: each existing write path is inspected.
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "async function startSuggestedConsultation");
const startNewChat = sourceBetween(pageSource, "async function startNewChat()", "function selectSession(");
const startNewChat = sourceBetween(pageSource, "async function startNewChat(", "function selectSession(");
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface");
@@ -14,7 +14,10 @@ test("consult history uses the checkpoint tail and puts the summary after the ti
assert.match(consultRoute, /consultationHistoryWindow\(chatSession\.messages, contextSummary/);
assert.match(consultRoute, /SESSION_CONTEXT_SUMMARY_HEADING|consultationUserTurnContent/);
assert.match(consultRoute, /summaryText: historyWindow\.summaryText/);
assert.match(consultRoute, /droppedCount: historyWindow\.droppedCount/);
assert.match(history, /【会话摘要(服务端维护)】/);
assert.match(history, /droppedRoundsMarker/);
assert.doesNotMatch(history, /16_000|16000/);
const helper = history.slice(history.indexOf("export function consultationUserTurnContent"));
const timeLine = helper.indexOf("input.currentTime");
const summaryLine = helper.indexOf("SESSION_CONTEXT_SUMMARY_HEADING");
@@ -33,9 +36,13 @@ test("consult retries once on context overflow with summary plus the last pair",
test("consult checkpoints the session summary after a successful completion", () => {
assert.match(consultRoute, /void checkpointConsultationContext\(\)/);
assert.match(consultRoute, /checkpointSessionContextSummary/);
assert.match(consultRoute, /contextWindow: sessionContextWindow/);
assert.match(consultRoute, /context_summary ->>updatedAt|context_summary->>updatedAt/);
assert.match(consultRoute, /console\.warn\("session context summary failed"/);
assert.doesNotMatch(consultRoute, /AbortSignal\.timeout\(\s*15/);
const summary = readFileSync(new URL("../src/lib/session-context-summary.ts", import.meta.url), "utf8");
assert.match(summary, /consultationHistoryCheckpointChars/);
assert.doesNotMatch(summary, /CONSULTATION_HISTORY_TAIL_MAX_CHARS/);
});
test("the context summary migration only adds one jsonb column", () => {
@@ -2,11 +2,14 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO,
CONSULTATION_HISTORY_MESSAGE_CHARS,
clipConsultationHistoryText,
consultationHistoryCheckpointChars,
consultationHistoryFromStoredMessages,
consultationHistoryWindow,
consultationUserTurnContent,
droppedRoundsMarker,
historyBudgetChars,
isContextOverflowError,
omissionMarker,
@@ -77,6 +80,61 @@ test("historyBudgetChars uses 64k, 32k, and null-as-128k windows", () => {
assert.equal(historyBudgetChars(undefined), 40_000);
});
test("checkpoint threshold stays below the history budget for legal context windows", () => {
assert.equal(CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO, 0.4);
const windows = [200_000, 128_000, 64_000, 32_000, null] as const;
for (const window of windows) {
const budget = historyBudgetChars(window);
const threshold = consultationHistoryCheckpointChars(window);
assert.ok(threshold < budget, `window=${window}`);
}
// 128k keeps the previous 16,000-character checkpoint.
assert.equal(consultationHistoryCheckpointChars(128_000), 16_000);
assert.equal(consultationHistoryCheckpointChars(null), 16_000);
assert.equal(consultationHistoryCheckpointChars(64_000), 2_400);
assert.equal(consultationHistoryCheckpointChars(32_000), 1_600);
});
test("dropped whole turns leave an omission marker in the user-turn summary slot", () => {
const body = "x".repeat(3_000);
const history = consultationHistoryWindow(numberedMessages(20, () => body), null, {
contextWindow: 128_000,
});
assert.ok(history.droppedCount > 0);
const withoutSummary = consultationUserTurnContent({
currentTime: "当前时间:2026-09-16 12:00(中国)",
instruction: "先加载 Jyotish Skill",
summaryText: history.summaryText,
droppedCount: history.droppedCount,
question: "刚才你说的那个时间",
});
assert.match(
withoutSummary,
new RegExp(`更早的 ${history.droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要`),
);
assert.equal(withoutSummary.includes(SESSION_CONTEXT_SUMMARY_HEADING), false);
const withSummary = consultationUserTurnContent({
currentTime: "当前时间:2026-09-16 12:00(中国)",
instruction: "先加载 Jyotish Skill",
summaryText: "先前结论",
droppedCount: history.droppedCount,
question: "刚才你说的那个时间",
});
assert.match(withSummary, new RegExp(`更早的 ${history.droppedCount} 轮问答已并入上面的会话摘要`));
assert.ok(withSummary.indexOf(SESSION_CONTEXT_SUMMARY_HEADING) < withSummary.indexOf("刚才你说的那个时间"));
const kept = consultationUserTurnContent({
currentTime: "当前时间:2026-09-16 12:00(中国)",
instruction: "先加载 Jyotish Skill",
summaryText: "先前结论",
droppedCount: 0,
question: "刚才你说的那个时间",
});
assert.equal(kept.includes("更早的"), false);
assert.equal(droppedRoundsMarker(0, true), "");
});
test("stored consultation history can skip the in-flight request id", () => {
const history = consultationHistoryFromStoredMessages([
{ role: "user", text: "old", requestId: "keep" },
+43 -3
View File
@@ -3,13 +3,14 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
consultationHistoryCheckpointChars,
} from "../src/lib/consultation-session-history.ts";
import {
buildSummaryPrompt,
checkpointSessionContextSummary,
generateSessionContextSummary,
messagesForSummaryInput,
resolveInheritedContextSummary,
sanitizeSessionContextSummary,
shouldCheckpoint,
writeSessionContextSummary,
@@ -24,10 +25,14 @@ function overBudgetConversation() {
];
}
test("checkpoint triggers only when the tail exceeds 16_000 characters", () => {
assert.equal(CONSULTATION_HISTORY_TAIL_MAX_CHARS, 16_000);
test("checkpoint triggers only when the tail exceeds the derived threshold", () => {
// Former value: hard-coded CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000.
// 128k still checkpoints at 16_000 (0.4 × 40_000 budget).
assert.equal(consultationHistoryCheckpointChars(128_000), 16_000);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(15_999) }], null), false);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(16_001) }], null), true);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(2_400) }], null, { contextWindow: 64_000 }), false);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(2_401) }], null, { contextWindow: 64_000 }), true);
});
test("checkpoint prompt omits the last question-answer pair", () => {
@@ -108,6 +113,41 @@ test("writeSessionContextSummary abandons when updatedAt does not match", async
assert.equal(result, "abandoned");
});
test("inherited context summary copies owned text and skips foreign or empty sources", async () => {
const source = {
version: 1 as const,
text: "已问过的问题\n事业时机",
throughRequestId: "a1",
throughMessageIndex: 3,
messageCount: 4,
updatedAt: "2026-09-15T00:00:00.000Z",
};
const copied = await resolveInheritedContextSummary({
continuedFromSessionId: "11111111-1111-4111-8111-111111111111",
loadOwnedSummary: async () => source,
});
assert.deepEqual(copied, source);
const foreign = await resolveInheritedContextSummary({
continuedFromSessionId: "22222222-2222-4222-8222-222222222222",
loadOwnedSummary: async () => null,
});
assert.equal(foreign, null);
const empty = await resolveInheritedContextSummary({
continuedFromSessionId: "11111111-1111-4111-8111-111111111111",
loadOwnedSummary: async () => ({ version: 1, text: " " }),
});
assert.equal(empty, null);
const skipped = await resolveInheritedContextSummary({
loadOwnedSummary: async () => {
throw new Error("should not load");
},
});
assert.equal(skipped, null);
});
test("checkpoint writes a new summary when the tail is over budget", async () => {
const result = await checkpointSessionContextSummary({
messages: overBudgetConversation(),