fix(consult): drop traces, budget checkpoints, silent summary inherit
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:
@@ -330,6 +330,7 @@ export async function POST(request: Request) {
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const sessionContextWindow = sessionModel.contextWindow;
|
||||
|
||||
if (parsed.data.entrypoint === "birth_time_rectification") {
|
||||
return NextResponse.json(
|
||||
@@ -353,7 +354,7 @@ export async function POST(request: Request) {
|
||||
// Client `history` stays in the request schema for old bundles and is not read.
|
||||
const contextSummary = parseSessionContextSummary(chatSession.context_summary);
|
||||
const historyWindow = consultationHistoryWindow(chatSession.messages, contextSummary, {
|
||||
contextWindow: sessionModel.contextWindow,
|
||||
contextWindow: sessionContextWindow,
|
||||
});
|
||||
const storedHistory = historyWindow.tail;
|
||||
const userControlledPrompt = [
|
||||
@@ -596,6 +597,7 @@ export async function POST(request: Request) {
|
||||
await checkpointSessionContextSummary({
|
||||
messages: sessionRow.messages,
|
||||
summary: sessionRow.context_summary,
|
||||
contextWindow: sessionContextWindow,
|
||||
generateText: (prompt, signal) => generateSessionContextSummaryText(summaryModel, prompt, signal),
|
||||
update: async (summary, seenUpdatedAt) => {
|
||||
let query = supabase.from("chat_sessions")
|
||||
@@ -875,6 +877,7 @@ export async function POST(request: Request) {
|
||||
instruction: modeInstruction,
|
||||
extra: generalDailyContextPrompt(generalDailyContext),
|
||||
summaryText: historyWindow.summaryText,
|
||||
droppedCount: historyWindow.droppedCount,
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
}),
|
||||
},
|
||||
@@ -1271,6 +1274,7 @@ export async function POST(request: Request) {
|
||||
instruction: generalNoMinuteInstruction(Boolean(generalDailyContext)),
|
||||
extra: generalDailyContextPrompt(generalDailyContext),
|
||||
summaryText: historyWindow.summaryText,
|
||||
droppedCount: historyWindow.droppedCount,
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
}),
|
||||
},
|
||||
@@ -1355,6 +1359,7 @@ export async function POST(request: Request) {
|
||||
name,
|
||||
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
|
||||
summaryText: historyWindow.summaryText,
|
||||
droppedCount: historyWindow.droppedCount,
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
}),
|
||||
},
|
||||
@@ -1379,6 +1384,7 @@ export async function POST(request: Request) {
|
||||
name,
|
||||
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
|
||||
summaryText: historyWindow.summaryText,
|
||||
droppedCount: historyWindow.droppedCount,
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chatSessionCreateSchema, ChatSessionBodyTooLargeError, readChatSessionJson } from "@/lib/chat-session-write-contract";
|
||||
import {
|
||||
chatSessionCreateInsertRow,
|
||||
chatSessionCreateSchema,
|
||||
ChatSessionBodyTooLargeError,
|
||||
readChatSessionJson,
|
||||
} from "@/lib/chat-session-write-contract";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { resolveInheritedContextSummary } from "@/lib/session-context-summary";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
@@ -94,13 +100,28 @@ export async function POST(request: Request) {
|
||||
}
|
||||
const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request));
|
||||
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
|
||||
const { id, updated_at: _ignoredClientClock, ...values } = parsed.data;
|
||||
const { error } = await supabase.from("chat_sessions").insert({
|
||||
id,
|
||||
user_id: user.id,
|
||||
...values,
|
||||
updated_at: new Date().toISOString(),
|
||||
const { id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, ...values } = parsed.data;
|
||||
const inheritedSummary = await resolveInheritedContextSummary({
|
||||
continuedFromSessionId,
|
||||
loadOwnedSummary: async (sourceId) => {
|
||||
const { data } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("context_summary")
|
||||
.eq("id", sourceId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
return data?.context_summary ?? null;
|
||||
},
|
||||
});
|
||||
const { error } = await supabase.from("chat_sessions").insert(
|
||||
chatSessionCreateInsertRow({
|
||||
id,
|
||||
userId: user.id,
|
||||
values,
|
||||
inheritedSummary,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -135,7 +135,7 @@ export type ConsultationRunParams = {
|
||||
uiPreviewMode: MutableRefObject<string | null>;
|
||||
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
|
||||
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
|
||||
startNewChat: () => Promise<ChatSession | null>;
|
||||
startNewChat: (options?: { continuedFromSessionId?: string }) => Promise<ChatSession | null>;
|
||||
continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise<void>;
|
||||
refreshAccount: () => Promise<void>;
|
||||
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
|
||||
|
||||
@@ -139,7 +139,11 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
|
||||
}
|
||||
|
||||
async function persistSession(session: ChatSession, mode: "create" | "update" = "update") {
|
||||
async function persistSession(
|
||||
session: ChatSession,
|
||||
mode: "create" | "update" = "update",
|
||||
options?: { continuedFromSessionId?: string },
|
||||
) {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
|
||||
const values = mode === "create"
|
||||
@@ -153,6 +157,9 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
chart_profile_id: session.chartProfileId,
|
||||
chart_profile_name: session.chartProfileName,
|
||||
chart_profile_role: session.chartProfileRole,
|
||||
...(options?.continuedFromSessionId
|
||||
? { continued_from_session_id: options.continuedFromSessionId }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
title: session.title,
|
||||
@@ -192,7 +199,10 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
|
||||
async function continueInNewChat(prompt: { question: string; theme: Theme }) {
|
||||
setSessionFullPrompt(null);
|
||||
const created = await startNewChat();
|
||||
const sourceSessionId = activeSessionId;
|
||||
const created = await startNewChat(
|
||||
sourceSessionId ? { continuedFromSessionId: sourceSessionId } : undefined,
|
||||
);
|
||||
if (!created) return;
|
||||
setDraft(prompt.question);
|
||||
setDraftTheme(prompt.theme);
|
||||
@@ -294,7 +304,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewChat(): Promise<ChatSession | null> {
|
||||
async function startNewChat(options?: { continuedFromSessionId?: string }): Promise<ChatSession | null> {
|
||||
if (!account || !modelCatalog || creatingSession) return null;
|
||||
const nextSession = {
|
||||
...createSession(modelCatalog.defaultModelId),
|
||||
@@ -312,7 +322,13 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
setComposerNotice("");
|
||||
setRequestError(null);
|
||||
try {
|
||||
await persistSession(nextSession, "create");
|
||||
await persistSession(
|
||||
nextSession,
|
||||
"create",
|
||||
options?.continuedFromSessionId
|
||||
? { continuedFromSessionId: options.continuedFromSessionId }
|
||||
: undefined,
|
||||
);
|
||||
return nextSession;
|
||||
} catch (caught) {
|
||||
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
|
||||
|
||||
@@ -59,6 +59,7 @@ export const chatSessionCreateSchema = z.object({
|
||||
messages: z.array(chatMessageSchema).max(0),
|
||||
session_type: z.enum(["consultation", "birth_time_rectification"]),
|
||||
rectification_case_id: z.string().uuid().nullable(),
|
||||
continued_from_session_id: z.string().uuid().optional(),
|
||||
...chartBindingSchema,
|
||||
updated_at: z.string().datetime().optional(),
|
||||
}).strict();
|
||||
@@ -148,11 +149,41 @@ export type ChatSessionCreate = Readonly<{
|
||||
messages: readonly [];
|
||||
session_type: "consultation" | "birth_time_rectification";
|
||||
rectification_case_id: string | null;
|
||||
continued_from_session_id?: string;
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
}>;
|
||||
|
||||
export type ChatSessionCreateInsertValues = Readonly<{
|
||||
title: string;
|
||||
theme: ConsultationDomain;
|
||||
model_id: string;
|
||||
messages: readonly unknown[];
|
||||
session_type: "consultation" | "birth_time_rectification";
|
||||
rectification_case_id: string | null;
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
}>;
|
||||
|
||||
export function chatSessionCreateInsertRow(input: {
|
||||
id: string;
|
||||
userId: string;
|
||||
values: ChatSessionCreateInsertValues;
|
||||
inheritedSummary?: unknown;
|
||||
updatedAt: string;
|
||||
}): Record<string, unknown> {
|
||||
const row: Record<string, unknown> = {
|
||||
id: input.id,
|
||||
user_id: input.userId,
|
||||
...input.values,
|
||||
updated_at: input.updatedAt,
|
||||
};
|
||||
if (input.inheritedSummary) row.context_summary = input.inheritedSummary;
|
||||
return row;
|
||||
}
|
||||
|
||||
export type ChatSessionWrite = Readonly<{
|
||||
title: string;
|
||||
theme: ConsultationDomain;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
export const CONSULTATION_HISTORY_LIMIT = 12;
|
||||
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
|
||||
export const CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000;
|
||||
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
|
||||
export const CONSULTATION_HISTORY_CHAR_PER_TOKEN = 1.5;
|
||||
export const CONSULTATION_HISTORY_BUDGET_MIN_CHARS = 4_000;
|
||||
export const CONSULTATION_HISTORY_BUDGET_MAX_CHARS = 40_000;
|
||||
export const DEFAULT_MODEL_CONTEXT_WINDOW = 128_000;
|
||||
// 0.4 of the history budget. At the default 128k window the budget is 40,000,
|
||||
// so the checkpoint fires at 16,000 — the previous hard-coded threshold.
|
||||
// Smaller windows then checkpoint before the tail can exceed the budget.
|
||||
export const CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO = 0.4;
|
||||
|
||||
export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维护)】";
|
||||
|
||||
@@ -51,6 +54,15 @@ export function historyBudgetChars(contextWindow: number | null | undefined): nu
|
||||
);
|
||||
}
|
||||
|
||||
export function consultationHistoryCheckpointChars(contextWindow: number | null | undefined): number {
|
||||
const budget = historyBudgetChars(contextWindow);
|
||||
const threshold = Math.floor(budget * CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO);
|
||||
if (!(threshold < budget)) {
|
||||
throw new Error("consultation history checkpoint threshold must stay below the history budget");
|
||||
}
|
||||
return threshold;
|
||||
}
|
||||
|
||||
export function parseSessionContextSummary(value: unknown): SessionContextSummaryV1 | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const row = value as Record<string, unknown>;
|
||||
@@ -74,6 +86,13 @@ export function omissionMarker(omittedChars: number): string {
|
||||
return `……(以下省略 ${omittedChars} 字,结论已并入会话摘要)`;
|
||||
}
|
||||
|
||||
export function droppedRoundsMarker(droppedCount: number, hasSummary: boolean): string {
|
||||
if (droppedCount <= 0) return "";
|
||||
return hasSummary
|
||||
? `……(更早的 ${droppedCount} 轮问答已并入上面的会话摘要)`
|
||||
: `……(更早的 ${droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要)`;
|
||||
}
|
||||
|
||||
export function clipConsultationHistoryText(text: string): string {
|
||||
if (text.length <= CONSULTATION_HISTORY_MESSAGE_CHARS) return text;
|
||||
const omitted = text.length - CONSULTATION_HISTORY_MESSAGE_CHARS;
|
||||
@@ -165,16 +184,18 @@ export function consultationUserTurnContent(input: {
|
||||
instruction: string;
|
||||
extra?: string;
|
||||
summaryText?: string | null;
|
||||
droppedCount?: number;
|
||||
question: string;
|
||||
}): string {
|
||||
const summary = input.summaryText?.trim() ?? "";
|
||||
const dropped = droppedRoundsMarker(input.droppedCount ?? 0, Boolean(summary));
|
||||
return [
|
||||
input.currentTime,
|
||||
input.name ? `用户称呼:${input.name}` : "",
|
||||
input.instruction,
|
||||
input.extra ?? "",
|
||||
input.summaryText?.trim()
|
||||
? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${input.summaryText.trim()}`
|
||||
: "",
|
||||
summary ? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${summary}` : "",
|
||||
dropped,
|
||||
input.question,
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
|
||||
import {
|
||||
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
|
||||
consultationHistoryCheckpointChars,
|
||||
lastConsultationPair,
|
||||
parseSessionContextSummary,
|
||||
storedConsultationTurns,
|
||||
@@ -98,7 +98,7 @@ export function sanitizeSessionContextSummary(raw: string): string | null {
|
||||
export function tailCharCount(
|
||||
messages: unknown,
|
||||
summary: SessionContextSummaryV1 | null,
|
||||
options: { excludeRequestId?: string } = {},
|
||||
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
|
||||
): number {
|
||||
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
|
||||
const tail = summary
|
||||
@@ -110,9 +110,10 @@ export function tailCharCount(
|
||||
export function shouldCheckpoint(
|
||||
messages: unknown,
|
||||
summary: SessionContextSummaryV1 | null,
|
||||
options: { excludeRequestId?: string } = {},
|
||||
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
|
||||
): boolean {
|
||||
return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS;
|
||||
return tailCharCount(messages, summary, options)
|
||||
> consultationHistoryCheckpointChars(options.contextWindow);
|
||||
}
|
||||
|
||||
export function messagesForSummaryInput(
|
||||
@@ -215,6 +216,19 @@ export function nextSessionContextSummary(
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveInheritedContextSummary(input: {
|
||||
continuedFromSessionId?: string | null;
|
||||
loadOwnedSummary: (sessionId: string) => Promise<unknown>;
|
||||
}): Promise<SessionContextSummaryV1 | null> {
|
||||
const sourceId = input.continuedFromSessionId?.trim();
|
||||
if (!sourceId) return null;
|
||||
try {
|
||||
return parseSessionContextSummary(await input.loadOwnedSummary(sourceId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSessionContextSummary(input: {
|
||||
seenUpdatedAt: string | null;
|
||||
summary: SessionContextSummaryV1;
|
||||
@@ -228,13 +242,17 @@ export async function checkpointSessionContextSummary(input: {
|
||||
messages: unknown;
|
||||
summary: unknown;
|
||||
excludeRequestId?: string;
|
||||
contextWindow?: number | null;
|
||||
now?: () => Date;
|
||||
generateText: (prompt: string, signal?: AbortSignal) => Promise<string>;
|
||||
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<"written" | "skipped" | "abandoned" | "failed"> {
|
||||
const previous = parseSessionContextSummary(input.summary);
|
||||
if (!shouldCheckpoint(input.messages, previous, { excludeRequestId: input.excludeRequestId })) {
|
||||
if (!shouldCheckpoint(input.messages, previous, {
|
||||
excludeRequestId: input.excludeRequestId,
|
||||
contextWindow: input.contextWindow,
|
||||
})) {
|
||||
return "skipped";
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user