Product decision 2026-09-27 (D1-D4), overriding the BUG-1038 login-return stash and the "latest session" default landing: - A full load of / without ?c= (login, typed address, bookmark, refresh of bare /) lands on the current person's blank starter home; an existing empty draft of that person is reused, otherwise one is created locally. - ?c= and ?new=1 are unchanged; a refresh inside a conversation keeps its ?c=. - The login-return stash is removed: redirectToLogin and sidebar links no longer write it, bootstrap no longer reads it and clears a leftover value once. replace-selected, the lookup origin/other-subject branch and the warm resumeRectification dependency go with it. - A background answer being recovered no longer takes over the blank home (same rule BUG-1015 set for ?new=1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
285 lines
16 KiB
TypeScript
285 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { runConsultationRecoveryPoll } from "../src/lib/consultation-recovery-poll.ts";
|
|
import { ConsultationStatusError, createSession } from "../src/lib/home-cloud-sync.ts";
|
|
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
|
|
import { emptyProfile, pendingConsultationStorageKey, previewModelCatalog, type Account } from "../src/lib/home-types.ts";
|
|
import { homeSurface as source } from "./home-surface.ts";
|
|
|
|
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
|
const sendSource = source.slice(source.indexOf(" async function send("), source.indexOf("\n\n consultationReplay.current"));
|
|
const stopSource = source.slice(source.indexOf(" async function stopResponse("), source.indexOf("\n\n function completeConsultationInterface"));
|
|
|
|
test("consultation does not persist the optimistic user message before generation starts", () => {
|
|
// Former value: `await persistSession(userSession)` ran after the undo window and
|
|
// before `/api/consult`, locking the client full-replace write. The server now
|
|
// appends the question after a successful reserve.
|
|
const undo = sendSource.indexOf("await waitForUndoWindow(controller.signal)");
|
|
const consult = sendSource.indexOf('fetch("/api/consult"');
|
|
|
|
assert.ok(undo >= 0 && undo < consult);
|
|
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
|
|
assert.doesNotMatch(sendSource, /问题保存失败,未开始生成;问题已放回输入框。/);
|
|
});
|
|
|
|
test("only explicit stop can request consultation cancellation", () => {
|
|
const streamCatch = sendSource.indexOf(" } catch (caught) {", sendSource.indexOf('fetch("/api/consult"'));
|
|
|
|
assert.equal(source.match(/confirmCancellation\(/g)?.length, 2);
|
|
assert.match(stopSource, /await confirmCancellation\(/);
|
|
assert.ok(streamCatch >= 0);
|
|
assert.doesNotMatch(sendSource.slice(streamCatch), /confirmCancellation\(|\/api\/consult\/cancel/);
|
|
});
|
|
|
|
test("durable partial stop cancels before preserving content and recovers completed conflicts", () => {
|
|
const partialStop = stopSource.slice(
|
|
stopSource.indexOf(" if (pending.partialReply)"),
|
|
stopSource.indexOf("\n updateSession(pending.sessionId, () => pending.previousSession)"),
|
|
);
|
|
const cancel = partialStop.indexOf("await requestCancellation(pending.requestId)");
|
|
const conflict = partialStop.indexOf("error.status === 409");
|
|
const recoveryPersist = partialStop.slice(conflict);
|
|
|
|
assert.ok(cancel >= 0 && conflict > cancel);
|
|
// Former value: persistSession(stoppedSession) after cancel. Refunded partial
|
|
// answers stay in page memory only and must not overwrite the server transcript.
|
|
assert.equal(partialStop.indexOf("await persistSession(stoppedSession)"), -1);
|
|
assert.doesNotMatch(recoveryPersist, /persistSession\(stoppedSession\)/);
|
|
assert.match(recoveryPersist, /fetchConsultationStatus\(pending.sessionId, pending.requestId\)/);
|
|
assert.match(recoveryPersist, /status.status === "completed"[\s\S]*?fetchSessionDetail\(pending.sessionId, modelCatalog\)/);
|
|
assert.match(partialStop, /已停止回答,现有内容已保留,本次点数已退回。/);
|
|
assert.match(source, /停止回答,保留已生成内容并退回本次点数/);
|
|
assert.match(source, /停止回答,保留现有内容并申请退回本次点数/);
|
|
});
|
|
|
|
test("explicit consultation HTTP failures unlock instead of entering recovery", () => {
|
|
assert.match(sendSource, /!response\.ok[\s\S]*throw new ConsultationResponseError\([\s\S]*response\.status/);
|
|
const explicitFailure = sendSource.slice(
|
|
sendSource.indexOf("caught instanceof ConsultationResponseError"),
|
|
sendSource.indexOf("if (!cancelled && ownsInterface && pendingConsultation.current)"),
|
|
);
|
|
assert.match(explicitFailure, /caught\.message === "request_conflict"[\s\S]*setConsultationPhase\("recovering"\)/);
|
|
const genericFailure = explicitFailure.slice(explicitFailure.indexOf("setRequestError({ sessionId, message: caught.message })"));
|
|
assert.match(genericFailure, /setRequestError\(\{ sessionId, message: caught\.message \}\)/);
|
|
assert.match(genericFailure, /completeConsultationInterface\(requestId\)/);
|
|
assert.doesNotMatch(genericFailure, /phase: "recovering"|setConsultationPhase\("recovering"\)/);
|
|
});
|
|
|
|
test("reserved consultations recover through the status endpoint", () => {
|
|
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
|
const poll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8");
|
|
assert.match(source, /\/api\/consult\/status\?sessionId=\$\{encodeURIComponent\(sessionId\)\}&requestId=\$\{encodeURIComponent\(requestId\)\}/);
|
|
assert.match(source, /fetch\("\/api\/consult\/status", \{ signal, cache: "no-store" \}\)/);
|
|
// 原值: 预留判断、1750ms 轮询和完成后的 fetchSessionDetail 都在 page.tsx。
|
|
// 新值: 预留判断在 home-bootstrap-run;轮询间隔和详情读取在 consultation-recovery-poll。
|
|
// 原因: 两段流程搬出首页。状态端点、1750ms、完成后拉详情都还在。
|
|
assert.match(bootstrapRun, /reservedConsultation\?\.status === "reserved"[\s\S]*nextSessions\.find\(\(session\) => session\.id === reservedConsultation\.sessionId\)/);
|
|
assert.match(source, /status\?\.status !== "reserved"[\s\S]*sessions\.find\(\(item\) => item\.id === status\.sessionId\)/);
|
|
assert.match(source, /status: "reserved" \| "completed" \| "cancelled"/);
|
|
assert.match(source, /readonly responseMessage\?: unknown/);
|
|
assert.match(source, /phase: "recovering"/);
|
|
assert.match(poll, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
|
|
assert.match(poll, /status\.status === "completed"[\s\S]*?fetchDetail\(deps\.pendingSessionId, deps\.modelCatalog, signal\)/);
|
|
assert.match(source, /window\.addEventListener\("online", onOnline\)/);
|
|
assert.match(source, /window\.addEventListener\("pageshow", onPageShow\)/);
|
|
assert.match(source, /网络已断开,回答仍在后台生成;联网后会自动恢复。/);
|
|
});
|
|
|
|
test("a missing reservation is replayed once instead of being polled until the question is lost", async () => {
|
|
// 原值: 按 page.tsx 恢复 effect 的源码切片匹配 404、重放和第 4 次放弃。
|
|
// 新值: 直接调用 runConsultationRecoveryPoll。逐步假计时器在 consultation-recovery-poll.test.ts。
|
|
// 原因: 轮询已搬出;这里确认第一次 404 只确认、不立刻丢掉问题。
|
|
assert.match(source, /class ConsultationStatusError extends Error[\s\S]*readonly status: number/);
|
|
assert.match(source, /throw new ConsultationStatusError\([\s\S]*response\.status/);
|
|
assert.match(source, /const consultationStatusMissingCount = useRef\(0\)/);
|
|
assert.match(source, /const consultationReplayStarted = useRef<string \| null>\(null\)/);
|
|
const host = globalThis as { window?: unknown };
|
|
if (host.window === undefined) host.window = globalThis;
|
|
const missing = { current: 0 };
|
|
let notice = "";
|
|
let replayed = 0;
|
|
const controller = new AbortController();
|
|
runConsultationRecoveryPoll({
|
|
pendingSessionId: "11111111-1111-4111-8111-111111111111",
|
|
pendingRequestId: "22222222-2222-4222-8222-222222222222",
|
|
modelCatalog: previewModelCatalog,
|
|
consultationStatusMissingCount: missing,
|
|
consultationReplayStarted: { current: null },
|
|
consultationReplay: { current: () => { replayed += 1; } },
|
|
pendingConsultation: { current: { phase: "recovering" } },
|
|
setComposerNotice: (value) => { notice = value; },
|
|
setSessions: () => undefined,
|
|
setActiveSessionId: () => undefined,
|
|
setPendingSessionId: () => undefined,
|
|
setPendingRequestId: () => undefined,
|
|
setConsultationPhase: () => undefined,
|
|
setStreamingReply: () => undefined,
|
|
setRequestError: () => undefined,
|
|
refreshAccount: () => undefined,
|
|
online: () => true,
|
|
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
|
|
}, controller.signal);
|
|
try {
|
|
for (let step = 0; step < 8; step += 1) await Promise.resolve();
|
|
assert.equal(missing.current, 1);
|
|
assert.equal(replayed, 0);
|
|
assert.equal(notice, "正在确认本次咨询请求是否已开始…");
|
|
} finally {
|
|
controller.abort();
|
|
}
|
|
});
|
|
|
|
test("tab-local pending ids drive strict bootstrap recovery before the global fallback", async () => {
|
|
// 原值:从 page.tsx 切 storedPending 分支,并用源码顺序断言它先于全局状态。
|
|
// 新值:调用 runHomeBootstrap。pending 仍覆盖全局 reserved;无 pending 时清除 storage key。
|
|
// 原因:启动流程搬出后,源码切片不再落在 page.tsx;优先级改由结果锁定。
|
|
const host = globalThis as { window?: unknown; localStorage?: Storage };
|
|
if (host.window === undefined) host.window = globalThis;
|
|
if (!host.localStorage) {
|
|
const data = new Map<string, string>();
|
|
host.localStorage = {
|
|
getItem: (key) => data.get(key) ?? null,
|
|
setItem: (key, value) => { data.set(key, String(value)); },
|
|
removeItem: (key) => { data.delete(key); },
|
|
clear: () => data.clear(),
|
|
key: (index) => Array.from(data.keys())[index] ?? null,
|
|
get length() { return data.size; },
|
|
};
|
|
}
|
|
const older = createSession(previewModelCatalog.defaultModelId);
|
|
const pendingSession = createSession(previewModelCatalog.defaultModelId);
|
|
const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
const removed: string[] = [];
|
|
let restored = "";
|
|
const reader: Account = {
|
|
user: { id: "account-1", email: "reader@example.com" },
|
|
avatar: null,
|
|
credits: 1,
|
|
isAdmin: false,
|
|
adminUrl: null,
|
|
rectificationPriceCredits: 1,
|
|
activeSubscription: null,
|
|
hasConfirmedBirthTime: false,
|
|
hasUsableBirthTime: false,
|
|
profile: emptyProfile,
|
|
};
|
|
const deps: HomeBootstrapDeps = {
|
|
sessionListReady: Promise.resolve(),
|
|
sessionListBoot: () => ({
|
|
sessions: [older, pendingSession],
|
|
rawRows: [],
|
|
cursor: null,
|
|
account: reader,
|
|
signedOut: false,
|
|
}),
|
|
uiPreview: { current: false },
|
|
uiPreviewMode: { current: null },
|
|
consultationStatusMissingCount: { current: 0 },
|
|
isDevelopment: false,
|
|
readSearch: () => "",
|
|
storage: {
|
|
getItem: (key) => key === pendingConsultationStorageKey
|
|
? JSON.stringify({ sessionId: pendingSession.id, requestId: storedRequestId, question: "继续" })
|
|
: null,
|
|
removeItem: (key) => { removed.push(key); },
|
|
},
|
|
restoreConsultationRecovery: (session, requestId) => { restored = `${session.id}:${requestId}`; },
|
|
setComposerNotice: () => undefined,
|
|
commit: {
|
|
setAccount: () => undefined,
|
|
setAccountError: () => undefined,
|
|
setActiveSessionId: () => undefined,
|
|
setBirthTimeAssessmentPhase: () => undefined,
|
|
setBirthTimeJourney: () => undefined,
|
|
setBootstrapPhase: () => undefined,
|
|
setGuidedJourneyPreview: () => undefined,
|
|
setHydrated: () => undefined,
|
|
setModelCatalog: () => undefined,
|
|
setOnboardingStep: () => undefined,
|
|
setProfile: () => undefined,
|
|
setProfileDraft: () => undefined,
|
|
setSessions: () => undefined,
|
|
setSessionsCursor: () => undefined,
|
|
setStartGreeting: () => undefined,
|
|
},
|
|
io: {
|
|
fetchModelCatalog: async () => previewModelCatalog,
|
|
fetchActiveConsultationStatus: async () => ({
|
|
sessionId: older.id,
|
|
requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
status: "reserved",
|
|
}),
|
|
fetchConsultationStatus: async () => ({
|
|
sessionId: pendingSession.id,
|
|
requestId: storedRequestId,
|
|
status: "reserved",
|
|
}),
|
|
resolveLookupBootstrap: async (input) => ({
|
|
selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false },
|
|
sessions: input.sessions,
|
|
notice: null,
|
|
}),
|
|
},
|
|
};
|
|
await runHomeBootstrap(deps, new AbortController().signal);
|
|
assert.equal(restored, `${pendingSession.id}:${storedRequestId}`);
|
|
assert.equal(removed.includes(pendingConsultationStorageKey), false);
|
|
|
|
// 原值:从拼接后的 `homeSurface` 中用两个跨文件 indexOf 边界切片。
|
|
// 新值:直接读取 page.tsx 的 pending storage effect。
|
|
// 原因:写入 sessionId/requestId/question 仍留在首页,不随启动流程搬走。
|
|
const pendingStorageMarker = " if (pendingSessionId && pendingRequestId) {";
|
|
const storageEffectStart = pageSource.lastIndexOf(" useEffect(() => {", pageSource.indexOf(pendingStorageMarker));
|
|
const storageSync = pageSource.slice(
|
|
storageEffectStart,
|
|
pageSource.indexOf("\n useEffect(() => {", pageSource.indexOf(pendingStorageMarker)),
|
|
);
|
|
assert.match(storageSync, /pendingSessionId && pendingRequestId[\s\S]*sessionStorage\.setItem\(pendingConsultationStorageKey,[\s\S]*sessionId: pendingSessionId,[\s\S]*requestId: pendingRequestId,[\s\S]*question:/);
|
|
assert.match(storageSync, /else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
|
|
});
|
|
|
|
test("pending storage keeps the question so a refresh during the undo window can replay", () => {
|
|
assert.match(source, /function readStoredPendingConsultation\(/);
|
|
assert.match(source, /typeof parsedPending\.question === "string" \? parsedPending\.question : ""/);
|
|
assert.match(sendSource, /sessionStorage\.setItem\(pendingConsultationStorageKey, JSON\.stringify\(\{[\s\S]*sessionId,[\s\S]*requestId,[\s\S]*question: originalQuestion/);
|
|
// 原值: homeSurface 里 `restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending)`。
|
|
// 新值: 同一调用在 home-bootstrap-run.ts。
|
|
// 原因: 启动恢复搬出首页,仍把 stored pending 交给真实 restore。
|
|
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
|
assert.match(bootstrapRun, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/);
|
|
assert.match(source, /stored\?\.question\?\.trim\(\)/);
|
|
assert.match(sendSource, /resumeRequestId/);
|
|
assert.match(sendSource, /questionAlreadyPresent/);
|
|
assert.match(source, /consultationReplay\.current = \(\) => \{[\s\S]*resumeRequestId: pending\.requestId/);
|
|
});
|
|
|
|
test("the first default consultation title is persisted with the user question", () => {
|
|
const userSessionBlock = sendSource.slice(
|
|
sendSource.indexOf("const userSession: ChatSession"),
|
|
sendSource.indexOf("const requestId = resumeRequestId ?? globalThis.crypto.randomUUID()"),
|
|
);
|
|
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && isGenericSessionTitle\(currentSession\.title\)[\s\S]*resolveSessionTitle\(question/);
|
|
// Former value: persistSession(userSession) before consult wrote the title by
|
|
// replacing the whole messages array. Title now comes from SQL append, and the
|
|
// completed metadata patch only updates title/theme/model/chart binding.
|
|
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
|
|
assert.match(sendSource, /await persistSession\(completedSession\)/);
|
|
assert.match(sendSource, /const modelTitle = streamedTitle \?\? reply\.title/);
|
|
assert.match(sendSource, /const completedTitle = modelTitle && !isGenericSessionTitle\(modelTitle\)/);
|
|
assert.match(sendSource, /resolveSessionTitle\(question, modelTitle/);
|
|
});
|
|
|
|
test("a truncated generation keeps the partial answer and does not wait for a successful run", () => {
|
|
const stream = sendSource.slice(sendSource.indexOf('fetch("/api/consult"'));
|
|
assert.match(stream, /event\.code === "answer_truncated"/);
|
|
assert.match(stream, /truncatedFailure = event/);
|
|
assert.match(stream, /const truncatedSession: ChatSession = \{[\s\S]*role: "assistant"[\s\S]*text: reply\.text/);
|
|
// Former value: persistSession(truncatedSession) saved a refunded partial answer.
|
|
assert.doesNotMatch(stream, /await persistSession\(truncatedSession\)/);
|
|
assert.match(stream, /setComposerNotice\(truncatedFailure\.message\)/);
|
|
assert.match(stream, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
|
|
assert.doesNotMatch(stream.slice(stream.indexOf("if (truncatedFailure)")), /runCompleted = true/);
|
|
});
|