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
211 lines
8.9 KiB
TypeScript
211 lines
8.9 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
import { runInNewContext } from "node:vm";
|
||
import ts from "typescript";
|
||
|
||
import { createSession } from "../src/lib/home-cloud-sync.ts";
|
||
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
|
||
import { pendingConsultationStorageKey, previewModelCatalog, emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts";
|
||
import { isUnsavedEmptyConsultation } from "../src/lib/session-list-filter.ts";
|
||
import type { SessionListBoot } from "../src/lib/session-list-context.tsx";
|
||
|
||
const run = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
|
||
|
||
function sourceBetween(source: string, start: string, end: string): string {
|
||
const from = source.indexOf(start);
|
||
const to = source.indexOf(end, from);
|
||
assert.ok(from >= 0 && to > from, `missing source boundary: ${start}`);
|
||
return source.slice(from, to);
|
||
}
|
||
|
||
function installBrowserHosts() {
|
||
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; },
|
||
};
|
||
}
|
||
}
|
||
|
||
function bindRestore(sandbox: Record<string, unknown>) {
|
||
const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current =");
|
||
const compiled = ts.transpileModule(
|
||
`${recovery}\n__boundRestore = restoreConsultationRecovery;`,
|
||
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
|
||
).outputText;
|
||
const box: Record<string, unknown> = { __boundRestore: undefined, ...sandbox };
|
||
runInNewContext(compiled, box);
|
||
return box.__boundRestore as (session: ChatSession, requestId: string, stored?: unknown) => void;
|
||
}
|
||
|
||
function account(): Account {
|
||
return {
|
||
user: { id: "account-1", email: "reader@example.com" },
|
||
avatar: null,
|
||
credits: 3,
|
||
isAdmin: false,
|
||
adminUrl: null,
|
||
rectificationPriceCredits: 1,
|
||
activeSubscription: null,
|
||
hasConfirmedBirthTime: false,
|
||
hasUsableBirthTime: false,
|
||
profile: emptyProfile,
|
||
};
|
||
}
|
||
|
||
async function bootstrapWithRecovery(urlAction: "new-chat" | "none" | "keep") {
|
||
// 原值:把 page.tsx 激活段和 hook 里的 restore 一起塞进 vm。
|
||
// 新值:直接调用 runHomeBootstrap,restore 仍是 hook 里的真实函数。
|
||
// 原因:启动流程已搬出 page.tsx;调用整段函数还覆盖 new-chat 会新建空咨询。
|
||
installBrowserHosts();
|
||
const oldSession = createSession("test-model");
|
||
oldSession.messages = [{ role: "user", text: "测试问题" }];
|
||
const localSession = createSession("test-model");
|
||
const pending: { current: { sessionId: string; phase: string } | null } = { current: null };
|
||
const state = {
|
||
active: "",
|
||
pendingSession: "",
|
||
pendingRequest: "",
|
||
phase: "",
|
||
notice: "",
|
||
storedCleared: false,
|
||
sessions: [] as ChatSession[],
|
||
};
|
||
const writes: unknown[][] = [];
|
||
const memory = new Map<string, string>();
|
||
const sandbox = {
|
||
AbortController,
|
||
navigator: { onLine: true },
|
||
pendingConsultation: pending,
|
||
pendingConsultationStorageKey,
|
||
sessionStorage: {
|
||
getItem: (key: string) => memory.get(key) ?? null,
|
||
setItem: (key: string, value: string) => { memory.set(key, value); },
|
||
removeItem: (key: string) => { memory.delete(key); },
|
||
},
|
||
sessions: [localSession, oldSession],
|
||
consultSendBlockedByRectificationSession: () => false,
|
||
isGenericSessionTitle: () => false,
|
||
resolveSessionTitle: () => "新对话",
|
||
timestamp: () => 1,
|
||
updateSession: () => { assert.fail("the existing user question must not be duplicated"); },
|
||
setActiveSessionId: (id: string) => { state.active = id; },
|
||
setPendingSessionId: (id: string) => { state.pendingSession = id; },
|
||
setPendingRequestId: (id: string) => { state.pendingRequest = id; },
|
||
setConsultationPhase: (phase: string) => { state.phase = phase; },
|
||
setStreamingReply: () => undefined,
|
||
setComposerNotice: (notice: string) => { state.notice = notice; },
|
||
};
|
||
const restore = bindRestore(sandbox);
|
||
const boot: SessionListBoot = {
|
||
sessions: [localSession, oldSession],
|
||
rawRows: [],
|
||
cursor: null,
|
||
account: account(),
|
||
signedOut: false,
|
||
};
|
||
const deps: HomeBootstrapDeps = {
|
||
sessionListReady: Promise.resolve(),
|
||
sessionListBoot: () => boot,
|
||
uiPreview: { current: false },
|
||
uiPreviewMode: { current: null },
|
||
consultationStatusMissingCount: { current: 0 },
|
||
isDevelopment: false,
|
||
readSearch: () => "",
|
||
storage: {
|
||
getItem: () => null,
|
||
removeItem: () => undefined,
|
||
},
|
||
restoreConsultationRecovery: restore,
|
||
setComposerNotice: sandbox.setComposerNotice,
|
||
commit: {
|
||
setAccount: () => undefined,
|
||
setAccountError: () => undefined,
|
||
setActiveSessionId: sandbox.setActiveSessionId,
|
||
setBirthTimeAssessmentPhase: () => undefined,
|
||
setBirthTimeJourney: () => undefined,
|
||
setBootstrapPhase: () => undefined,
|
||
setGuidedJourneyPreview: () => undefined,
|
||
setHydrated: () => undefined,
|
||
setModelCatalog: () => undefined,
|
||
setOnboardingStep: () => undefined,
|
||
setProfile: () => undefined,
|
||
setProfileDraft: () => undefined,
|
||
setSessions: (sessions) => { state.sessions = sessions; },
|
||
setSessionsCursor: () => undefined,
|
||
setStartGreeting: () => undefined,
|
||
},
|
||
io: {
|
||
fetchModelCatalog: async () => previewModelCatalog,
|
||
fetchActiveConsultationStatus: async () => ({
|
||
status: "reserved",
|
||
sessionId: oldSession.id,
|
||
requestId: "33333333-3333-4333-8333-333333333333",
|
||
}),
|
||
resolveLookupBootstrap: async (input) => ({
|
||
selection: {
|
||
sessionId: localSession.id,
|
||
urlAction,
|
||
missing: false,
|
||
},
|
||
sessions: input.sessions,
|
||
notice: null,
|
||
}),
|
||
writeSessionUrl: (...args: unknown[]) => { writes.push(args); },
|
||
clearLoginSessionReturn: () => { state.storedCleared = true; },
|
||
},
|
||
};
|
||
await runHomeBootstrap(deps, new AbortController().signal);
|
||
return { state, pending, writes, localSession, oldSession };
|
||
}
|
||
|
||
test("new chat keeps its local landing while a reserved older consultation recovers", async () => {
|
||
const { state, pending, writes, oldSession } = await bootstrapWithRecovery("new-chat");
|
||
const landing = state.sessions[0];
|
||
assert.ok(landing);
|
||
assert.equal(state.active, landing.id);
|
||
assert.notEqual(landing.id, oldSession.id);
|
||
assert.equal(isUnsavedEmptyConsultation(landing), true);
|
||
assert.equal(state.notice, "");
|
||
assert.equal(state.storedCleared, true);
|
||
assert.deepEqual(writes, [[null, "replace"]]);
|
||
assert.equal(state.pendingSession, oldSession.id);
|
||
assert.equal(state.pendingRequest, "33333333-3333-4333-8333-333333333333");
|
||
assert.equal(state.phase, "recovering");
|
||
assert.equal(pending.current?.sessionId, oldSession.id);
|
||
assert.equal(pending.current?.phase, "recovering");
|
||
});
|
||
|
||
test("bare / keeps the blank starter home while a reserved older consultation recovers (BUG-1052)", async () => {
|
||
// 原值: 标题 "ordinary home landing still activates and announces the reserved consultation";
|
||
// none → state.active === oldSession.id,notice「回答仍在后台生成,正在自动恢复。」,storedCleared === false。
|
||
// 新值: none → 停在空白首页(本地空对话 localSession),notice 清空,恢复照常在后台进行;storedCleared === true。
|
||
// 原因: BUG-1052 D1——裸 `/` 永远落空白首页、不落旧对话,与 BUG-1015 的 ?new=1 同一条「不抢落点」规则;
|
||
// D3——启动一律清掉旧版本留下的存根值。「地址栏点名会话时恢复可以接管」改由下一条 keep 用例锁住。
|
||
const { state, pending, writes, localSession, oldSession } = await bootstrapWithRecovery("none");
|
||
assert.equal(state.active, localSession.id);
|
||
assert.equal(isUnsavedEmptyConsultation(state.sessions.find((item) => item.id === state.active)!), true);
|
||
assert.equal(state.notice, "");
|
||
assert.equal(state.storedCleared, true);
|
||
assert.deepEqual(writes, []);
|
||
assert.equal(state.pendingSession, oldSession.id);
|
||
assert.equal(state.phase, "recovering");
|
||
assert.equal(pending.current?.sessionId, oldSession.id);
|
||
});
|
||
|
||
test("a ?c= landing still activates and announces the reserved consultation", async () => {
|
||
const { state, pending, writes, oldSession } = await bootstrapWithRecovery("keep");
|
||
assert.equal(state.active, oldSession.id);
|
||
assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。");
|
||
assert.deepEqual(writes, []);
|
||
assert.equal(pending.current?.sessionId, oldSession.id);
|
||
});
|