diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f5cce3af..9d126c66 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3941,3 +3941,22 @@ - 相关记录:ERR-103(`docs/research/pre_work_error_ledger.md`,同一 Compose 现象的误诊,本次给出真实根因)、ERR-105(同一台跳板机磁盘耗尽的基础设施记录)、BUG-264(本次被卡住无法发布的修复) - 复发自:无 - 修复版本:本地未提交候选 + +## BUG-267 | “今日星语”永久停在“正在结合你的星盘写今天的星语”:请求被一个反向的出生时间守卫拦住,从未发出 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:`/` 首页“今日星语”卡片、首页 hero 的问候与标题、`/api/daily-starlanguage` 的失败归因与超时预算、Onboarding Agent 生成的欢迎语。 +- 用户现象:staging 上出生资料完整的账号打开首页,“今日星语”卡片一直显示“正在结合你的星盘写今天的星语。”,永远不出现 Agent 写的文案。同一次反馈里还问到:首页那三个问题是不是写死的,以及首页标题“今天想先理清什么?”为什么没换成登录后按时段问候的形式。 +- 触发条件:`birth_time_status` 为 `candidate`、`accepted` 或 `confirmed` 且已有可用出生时间的任何账号打开首页。也就是说,越是资料完整的账号越必然命中。 +- 根因:三处独立问题,都在同一屏上。 + 1. 每日星语 effect 的守卫是 `if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;`。`birthTimeDisplayState` 恰好在出生时间可用(`candidate`/`accepted`/`confirmed`)时返回非 null,因此条件的实际含义是「星盘可用时不要请求」,与卡片的渲染条件 `personalChartAvailable` 完全相反,请求从未发出,state 永远停在 `pending`。这个守卫在 BUG-265 之前就存在,但那时客户端还有 `buildDailyStarlanguageCard` 写死兜底把空状态遮住了;BUG-265 删掉兜底、保留守卫,于是暴露成永久等待态。 + 2. `/api/daily-starlanguage` 里 `/api/chart` 是唯一没有 `.catch()` 的引擎调用(其余四层都有),且 `engineTimeoutMs` 只有 8 秒、`agentTimeoutMs` 只有 30 秒。线上实测冷路径端到端 30.6 秒,紧贴 30 秒上限;首次调用在 9.5 秒就返回 `agent_generation_failed`,正是 8 秒引擎超时被当成模型失败上报。失败原因被压成同一个字符串,无法区分是引擎、模型目录还是模型输出。 + 3. 首页同时存在三套问候实现:`page.tsx` 的 `greetingForHour`(hero 第一行)、`starter-prompt.ts` 的 8 条静态随机池(hero 的 `h1`)、`onboarding-client.ts` 的 `createStartGreeting`(按时段+称呼,只用在 onboarding 气泡)。用户要求的按时段问候在第三套里,而 hero 标题读的是第二套,所以「改了却没生效」。同时 `/api/onboarding` 返回的 Agent 欢迎语在客户端被 `greeting: createStartGreeting(presentationName)` 覆盖,而 `onboarding.greeting` 在整个页面里没有任何渲染点,等于 Agent 每次都白写一句欢迎语。 +- 修复:守卫改成 `!hydrated || !profileComplete || !personalChartAvailable`,与卡片渲染个人内容的条件对齐,并在服务端报 unavailable 时延迟 5 秒重试一次后才落到失败态,卸载时清理定时器。路由给 `/api/chart` 补 `.catch()`,把生成结果改成判别联合,失败原因区分 `chart_unavailable` / `model_unavailable` / `agent_generation_failed`;`engineTimeoutMs` 提到 20 秒、`agentTimeoutMs` 提到 45 秒,仍在 `maxDuration = 60` 之内。问候收敛成一套:`createStartGreeting` 拆出 `createStartGreetingParts`,返回 `{salutation, question}`,hero 第一行用 salutation、`h1` 用 question,选中变体在离开首页时重抽;删除 `starter-prompt.ts` 与 `greetingForHour`。客户端不再覆盖 Agent 欢迎语,`onboarding.greeting` 落到 hero 说明行并保留原静态文案作为兜底。 +- 验证:线上先证明后端是好的——带登录 Cookie 直接调 staging `/api/daily-starlanguage`,冷路径 30.6 秒返回真实 `{trend, action, caution}`,第二次 1.6 秒命中 `agent_cache`;`/api/health` 确认部署 SHA 就是 `origin/staging` 头部,`jyotishApi` 检查为 ok,因此排除部署落后与引擎不可用。新增 4 条回归:每日星语请求条件必须与 `personalChartAvailable` 一致且源码中不得再出现 `birthTimeDisplayState(profile)`、失败必须重试一次且清理定时器、`/api/chart` 必须带 catch 且三种失败原因各自可辨、两个超时预算有下限断言;hero 断言 salutation/question 拆分与 Agent 欢迎语落点,并禁止 `starterPrompt`/`createStarterPrompt`/`greetingForHour` 复活。前端非数据库套件 1711/1724 通过,13 个失败全部是本机 Docker/PostgreSQL fixture(与 BUG-265 同一类环境失败,与本次无关);`tsc --noEmit` 与改动文件 ESLint 清洁。未做的验证:**没有在浏览器里看过修好后的首页**——本机没有 Python 引擎与模型密钥,无法起完整栈,卡片从 `pending` 到 `ready` 的实际观感、重试是否够用、以及 hero 换行后的排版都要等 staging 发布后确认。 +- 防复发:一个界面元素的「取数条件」必须和它的「渲染条件」写成同一个表达式,不能一边用 `personalChartAvailable` 渲染、一边用另一个语义相反的谓词决定是否请求。删除兜底文案时必须回头检查被兜底遮住的空状态路径是否本来就是坏的——BUG-265 删兜底是对的,但没有验证删掉之后真实账号能不能拿到内容,代价是上线即空转。同一个概念(这里是「登录后的问候」)不允许存在多套并行实现,否则改动必然落在没被渲染的那一套上。Agent 生成的字段如果没有渲染点,就不要生成,更不能在客户端覆盖后还继续消耗 token。 +- 相关记录:BUG-265(本次修复的直接前序:Agent 化改造正确但守卫未同步,且其「待跟进」已经预告了首屏等待态问题)、BUG-201(每日星语 effect 依赖完整 Profile 对象的既有决定,本次沿用其引用保持策略,未改依赖形状)、BUG-200(首页文案第一人称与真实性边界) +- 复发自:无 +- 修复版本:本地未提交候选 diff --git a/frontend/src/app/api/daily-starlanguage/route.ts b/frontend/src/app/api/daily-starlanguage/route.ts index 511eebdd..86c0b947 100644 --- a/frontend/src/app/api/daily-starlanguage/route.ts +++ b/frontend/src/app/api/daily-starlanguage/route.ts @@ -20,15 +20,19 @@ export const maxDuration = 60; type Profile = GlobalBirthProfile & { birthTimeStatus?: string }; type BirthPayload = NonNullable>>; type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard }; +type GenerationFailure = "chart_unavailable" | "model_unavailable" | "agent_generation_failed"; +type Generated = + | { readonly kind: "card"; readonly card: DailyStarlanguageCard } + | { readonly kind: "failed"; readonly reason: GenerationFailure }; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; -const engineTimeoutMs = 8_000; -const agentTimeoutMs = 30_000; +const engineTimeoutMs = 20_000; +const agentTimeoutMs = 45_000; const cacheLimit = 500; const state = globalThis as typeof globalThis & { jyotishaDailyStarlanguageCache?: Map; - jyotishaDailyStarlanguagePending?: Map>; + jyotishaDailyStarlanguagePending?: Map>; }; function cache() { @@ -85,9 +89,12 @@ function chartPoints(chart: Record) { } async function collectLayers(payload: BirthPayload, today: string) { - const chart = await fetchEngine("/api/chart", payload); - const points = chartPoints(chart); - if (!points) return null; + const chart = await fetchEngine("/api/chart", payload).catch((error: unknown) => { + console.warn("daily_starlanguage_chart_unavailable", error); + return null; + }); + const points = chart === null ? null : chartPoints(chart); + if (chart === null || !points) return null; const tomorrow = new Date(`${today}T00:00:00.000Z`); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); const withPoints = { ...payload, planets: points.planets, ascendant: points.ascendant }; @@ -106,21 +113,22 @@ async function collectLayers(payload: BirthPayload, today: string) { return { chart, vimshottari, narayana, varga, transit }; } -async function generateCard(payload: BirthPayload, profile: Profile, today: string) { +async function generateCard(payload: BirthPayload, profile: Profile, today: string): Promise { const layers = await collectLayers(payload, today); - if (!layers) return null; + if (!layers) return { kind: "failed", reason: "chart_unavailable" }; const modelId = process.env.DAILY_STARLANGUAGE_MODEL_ID?.trim() || "deepseek-v4-flash"; const catalog = await loadLanguageModelCatalog(); const model = catalog.models.find((entry) => entry.id === modelId) ?? catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null; - if (!model) return null; + if (!model) return { kind: "failed", reason: "model_unavailable" }; const evidence = dailyStarlanguageEvidence(layers, today, profile.birthTimeStatus === "confirmed"); const result = await getDailyStarlanguageAgent(model).generate( [{ role: "user", content: dailyStarlanguagePrompt(evidence) }], { abortSignal: AbortSignal.timeout(agentTimeoutMs) }, ); - return parseDailyStarlanguageText(result.text); + const card = parseDailyStarlanguageText(result.text); + return card ? { kind: "card", card } : { kind: "failed", reason: "agent_generation_failed" }; } function unavailable(reason: string) { @@ -156,19 +164,19 @@ export async function POST(request: Request) { } const inFlight = pending().get(key) ?? generateCard(payload, profile, today) - .catch((error: unknown) => { + .catch((error: unknown): Generated => { console.warn("daily_starlanguage_generation_failed", error); - return null; + return { kind: "failed", reason: "agent_generation_failed" }; }) .finally(() => pending().delete(key)); pending().set(key, inFlight); - const card = await inFlight; - if (!card) return unavailable("agent_generation_failed"); - writeCache(key, today, card); + const generated = await inFlight; + if (generated.kind === "failed") return unavailable(generated.reason); + writeCache(key, today, generated.card); return NextResponse.json({ status: "ok", - card, + card: generated.card, source: "agent", claim_status: "exploratory_unvalidated", boundary: "not_deterministic_prediction", diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index be9fe129..8dd05f35 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -45,7 +45,6 @@ import { applyBirthTimeDraftPatch, applyPersistedBirthTime, assistantIntentCopy, - birthTimeDisplayState, birthTimePersistenceValues, declaredBirthInputChanged, describeBirthTimeDraft, @@ -99,6 +98,7 @@ import { OnboardingAuthenticationError, type OnboardingContent, createStartGreeting, + createStartGreetingParts, isCurrentOnboardingRequest, onboardingProfileFingerprint, onboardingRequestIdentity, @@ -115,7 +115,6 @@ import { resolveSessionModelId, type PublicLanguageModelCatalog, } from "@/lib/public-models"; -import { createStarterPrompt } from "@/lib/starter-prompt"; import { selfHostedOtpActions } from "@/modules/identity/client"; const BirthTimeRectification = dynamic( @@ -289,12 +288,6 @@ const previewModelCatalog = parsePublicModelCatalog({ const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?"; -function greetingForHour(hour: number): string { - if (hour < 11) return "早上好"; - if (hour < 18) return "下午好"; - return "晚上好"; -} - const emptyProfile: Profile = { name: "", date: "", @@ -510,6 +503,8 @@ function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, re ].join("\n"); } +const dailyStarlanguageRetryDelayMs = 5_000; + async function fetchDailyStarlanguage(profile: Profile, signal: AbortSignal): Promise { const response = await fetch("/api/daily-starlanguage", { method: "POST", @@ -1035,7 +1030,7 @@ export default function Home() { const [birthTimeError, setBirthTimeError] = useState(""); const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState(null); const [startGreeting, setStartGreeting] = useState(""); - const [starterPrompt, setStarterPrompt] = useState(() => createStarterPrompt()); + const [starterGreetingSelection, setStarterGreetingSelection] = useState(() => Math.random()); const [presetMessageLength, setPresetMessageLength] = useState(0); const conversation = useRef(null); const accountTrigger = useRef(null); @@ -1043,7 +1038,7 @@ export default function Home() { const dialogReturnTarget = useRef(null); const closeButton = useRef(null); const onboardingPaywallShown = useRef(false); - const starterPromptUnseen = useRef(true); + const starterGreetingUnseen = useRef(true); const composerInput = useRef(null); const pendingConsultation = useRef(null); const cancellationRequests = useRef(new Map>()); @@ -1253,7 +1248,7 @@ export default function Home() { && !activeSession?.messages.length; const onboardingFormActive = !profileComplete && onboardingStep !== "name"; const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : ""; - const daypartGreeting = greetingForHour(new Date().getHours()); + const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection); const conversationAnchor = useConversationScrollAnchor( conversation, !rectificationSurfaceOpen && !starterHomeVisible, @@ -1685,7 +1680,6 @@ export default function Home() { const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint); if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; activeOnboardingRequestIdentity.current = requestIdentity; - const presentationName = profile.name; const controller = new AbortController(); setOnboarding(null); setOnboardingError(""); @@ -1698,10 +1692,7 @@ export default function Home() { .then((content) => { if (controller.signal.aborted || !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; - setOnboarding({ - ...content, - greeting: createStartGreeting(presentationName), - }); + setOnboarding(content); setOnboardingError(""); }) .catch((caught: unknown) => { @@ -1722,27 +1713,39 @@ export default function Home() { }, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete]); useEffect(() => { - if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return; + if (!hydrated || !profileComplete || !personalChartAvailable) return; const controller = new AbortController(); + let retryTimer: ReturnType | undefined; setDailyStarlanguage({ kind: "pending" }); - void fetchDailyStarlanguage(profile, controller.signal) - .then((next) => { - if (!controller.signal.aborted) setDailyStarlanguage(next); - }) - .catch(() => { - if (!controller.signal.aborted) setDailyStarlanguage({ kind: "unavailable" }); - }); - return () => controller.abort(); - }, [hydrated, profile, profileComplete]); + const attempt = (remainingRetries: number) => { + void fetchDailyStarlanguage(profile, controller.signal) + .then((next) => { + if (controller.signal.aborted) return; + if (next.kind === "unavailable" && remainingRetries > 0) { + retryTimer = setTimeout(() => attempt(remainingRetries - 1), dailyStarlanguageRetryDelayMs); + return; + } + setDailyStarlanguage(next); + }) + .catch(() => { + if (!controller.signal.aborted) setDailyStarlanguage({ kind: "unavailable" }); + }); + }; + attempt(1); + return () => { + controller.abort(); + if (retryTimer !== undefined) clearTimeout(retryTimer); + }; + }, [hydrated, personalChartAvailable, profile, profileComplete]); useEffect(() => { if (starterHomeVisible) { - starterPromptUnseen.current = false; + starterGreetingUnseen.current = false; return; } - if (starterPromptUnseen.current) return; - starterPromptUnseen.current = true; - setStarterPrompt(createStarterPrompt()); + if (starterGreetingUnseen.current) return; + starterGreetingUnseen.current = true; + setStarterGreetingSelection(Math.random()); }, [starterHomeVisible]); useEffect(() => { @@ -3387,11 +3390,12 @@ export default function Home() {
-

{daypartGreeting},{profile.name.trim()}。

-

{starterPrompt}

-

{personalChartAvailable - ? "从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。" - : "从你现在最关心的事开始;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}

+

{starterGreeting.salutation}

+

{starterGreeting.question}

+

{onboarding?.greeting + || (personalChartAvailable + ? "从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。" + : "从你现在最关心的事开始;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。")}

diff --git a/frontend/src/lib/onboarding-client.ts b/frontend/src/lib/onboarding-client.ts index 47116f9f..f7d13f0c 100644 --- a/frontend/src/lib/onboarding-client.ts +++ b/frontend/src/lib/onboarding-client.ts @@ -2,31 +2,36 @@ import { z } from "zod"; type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; -const greetingVariants: Record string)[]> = { +type GreetingVariant = { + readonly salutation: (name: string) => string; + readonly question: string; +}; + +const greetingVariants: Record = { morning: [ - (name) => `早上好,${name}。今天最想先看什么?`, - (name) => `${name},早安。今天最该关注哪件事?`, - (name) => `早上好,${name}。想从哪个问题开始?`, + { salutation: (name) => `早上好,${name}。`, question: "今天最想先看什么?" }, + { salutation: (name) => `${name},早安。`, question: "今天最该关注哪件事?" }, + { salutation: (name) => `早上好,${name}。`, question: "想从哪个问题开始?" }, ], noon: [ - (name) => `中午好,${name}。现在最想理清哪件事?`, - (name) => `${name},中午好。什么问题最需要方向?`, - (name) => `午间好,${name}。事业、关系或选择,想先聊哪个?`, + { salutation: (name) => `中午好,${name}。`, question: "现在最想理清哪件事?" }, + { salutation: (name) => `${name},中午好。`, question: "什么问题最需要方向?" }, + { salutation: (name) => `午间好,${name}。`, question: "事业、关系或选择,想先聊哪个?" }, ], afternoon: [ - (name) => `${name},下午好。现在最想推进哪件事?`, - (name) => `下午好,${name}。今天想先理清什么?`, - (name) => `${name},下午好。事业、关系或选择,想先聊哪个?`, + { salutation: (name) => `${name},下午好。`, question: "现在最想推进哪件事?" }, + { salutation: (name) => `下午好,${name}。`, question: "今天想先理清什么?" }, + { salutation: (name) => `${name},下午好。`, question: "事业、关系或选择,想先聊哪个?" }, ], evening: [ - (name) => `晚上好,${name}。今天最挂心的是哪件事?`, - (name) => `${name},晚上好。此刻最想聊哪件事?`, - (name) => `晚上好,${name}。把心里的问题告诉我吧。`, + { salutation: (name) => `晚上好,${name}。`, question: "今天最挂心的是哪件事?" }, + { salutation: (name) => `${name},晚上好。`, question: "此刻最想聊哪件事?" }, + { salutation: (name) => `晚上好,${name}。`, question: "把心里的问题告诉我吧。" }, ], "late-night": [ - (name) => `夜深了,${name}。此刻最想问什么?`, - (name) => `${name},还没休息吗?想从哪件事说起?`, - (name) => `这么晚还醒着,${name}。直接说说最在意的问题吧。`, + { salutation: (name) => `夜深了,${name}。`, question: "此刻最想问什么?" }, + { salutation: (name) => `${name},还没休息吗?`, question: "想从哪件事说起?" }, + { salutation: (name) => `这么晚还醒着,${name}。`, question: "直接说说最在意的问题吧。" }, ], }; @@ -86,11 +91,16 @@ export type OnboardingContent = { readonly suggestions: readonly OnboardingSuggestion[]; }; -export function createStartGreeting( +export type StartGreetingParts = { + readonly salutation: string; + readonly question: string; +}; + +export function createStartGreetingParts( name: string, now = new Date(), variantSelection = Math.random(), -): string { +): StartGreetingParts { const displayName = name.trim() || "你好"; const hour = now.getHours(); const period: GreetingPeriod = hour >= 5 && hour < 11 @@ -101,7 +111,18 @@ export function createStartGreeting( ? "afternoon" : hour >= 18 && hour < 23 ? "evening" : "late-night"; const variants = greetingVariants[period]; - return variants[Math.floor(variantSelection * variants.length)](displayName); + const index = Math.min(Math.max(Math.floor(variantSelection * variants.length), 0), variants.length - 1); + const variant = variants[index]!; + return { salutation: variant.salutation(displayName), question: variant.question }; +} + +export function createStartGreeting( + name: string, + now = new Date(), + variantSelection = Math.random(), +): string { + const parts = createStartGreetingParts(name, now, variantSelection); + return `${parts.salutation}${parts.question}`; } export function createOnboardingFallbackGreeting(name: string): string { diff --git a/frontend/src/lib/starter-prompt.ts b/frontend/src/lib/starter-prompt.ts deleted file mode 100644 index 838571c3..00000000 --- a/frontend/src/lib/starter-prompt.ts +++ /dev/null @@ -1,17 +0,0 @@ -const starterPrompts = [ - "今天想先理清什么?", - "现在最想弄明白哪件事?", - "今天想从哪里说起?", - "此刻最挂心的是什么?", - "最近有什么反复出现的问题?", - "眼下最想要一个答案的是什么?", - "想先把哪件事想清楚?", - "有什么事正等着你决定?", -] as const; - -export const starterPromptVariants: readonly string[] = starterPrompts; - -export function createStarterPrompt(selection = Math.random()): string { - const index = Math.floor(selection * starterPrompts.length); - return starterPrompts[Math.min(Math.max(index, 0), starterPrompts.length - 1)]; -} diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 94d07023..7aeba921 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -287,10 +287,10 @@ test("the starter heading is drawn per visit and the rectification card carries const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); - // Given: the heading reads from state seeded once per mount and redrawn when the home is left. - assert.match(source, /

\{starterPrompt\}<\/h1>/); - assert.match(source, /useState\(\(\) => createStarterPrompt\(\)\)/); - assert.match(source, /starterPromptUnseen\.current = true;\s*\n\s*setStarterPrompt\(createStarterPrompt\(\)\)/); + // Given: the heading reads from a variant seeded once per mount and redrawn when the home is left. + assert.match(source, /

\{starterGreeting\.question\}<\/h1>/); + assert.match(source, /useState\(\(\) => Math\.random\(\)\)/); + assert.match(source, /starterGreetingUnseen\.current = true;\s*\n\s*setStarterGreetingSelection\(Math\.random\(\)\)/); // Then: the rectification card's footer is the action alone, still flush right. const rectificationCard = source.slice( diff --git a/frontend/tests/daily-starlanguage.test.ts b/frontend/tests/daily-starlanguage.test.ts index 1df984b5..591f319d 100644 --- a/frontend/tests/daily-starlanguage.test.ts +++ b/frontend/tests/daily-starlanguage.test.ts @@ -180,3 +180,40 @@ test("the daily card is Agent-written per signed-in account, with no written-in assert.match(route, /status: "unavailable"/); assert.match(page, /今天的星语没能生成/); }); + +test("the home requests the card exactly when it renders one, and retries a failed day once", () => { + const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const effect = page.slice( + page.indexOf("if (!hydrated || !profileComplete || !personalChartAvailable) return;"), + page.indexOf("}, [hydrated, personalChartAvailable, profile, profileComplete]);"), + ); + + // Given: the card only renders personal content behind personalChartAvailable, so the fetch is gated on the same fact. + assert.match(page, /aria-busy=\{personalChartAvailable && dailyStarlanguage\.kind === "pending"\}/); + assert.ok(effect.length > 0); + + // Then: an accepted, candidate or confirmed birth time no longer suppresses the request. + assert.doesNotMatch(page, /birthTimeDisplayState\(profile\)/); + + // And: one transient failure is retried before the card admits defeat, and the timer is cleared on teardown. + assert.match(effect, /next\.kind === "unavailable" && remainingRetries > 0/); + assert.match(effect, /attempt\(1\);/); + assert.match(effect, /clearTimeout\(retryTimer\)/); +}); + +test("no single engine layer can take the whole card down without saying which one", () => { + const route = readFileSync(new URL("../src/app/api/daily-starlanguage/route.ts", import.meta.url), "utf8"); + + // Given: every engine call, the chart included, fails into a reason instead of throwing the request away. + assert.match(route, /fetchEngine\("\/api\/chart", payload\)\.catch\(/); + assert.match(route, /reason: "chart_unavailable"/); + assert.match(route, /reason: "model_unavailable"/); + assert.match(route, /return unavailable\(generated\.reason\)/); + + // Then: the budgets leave room for a cold engine and a slow model inside the route's 60s ceiling. + const engineTimeout = Number(route.match(/const engineTimeoutMs = ([\d_]+);/)?.[1]?.replace(/_/g, "")); + const agentTimeout = Number(route.match(/const agentTimeoutMs = ([\d_]+);/)?.[1]?.replace(/_/g, "")); + assert.ok(engineTimeout >= 15_000, `engine timeout too tight for a cold chart: ${engineTimeout}`); + assert.ok(agentTimeout >= 45_000, `agent timeout too tight for observed generation: ${agentTimeout}`); + assert.match(route, /export const maxDuration = 60;/); +}); diff --git a/frontend/tests/onboarding-presentation.test.ts b/frontend/tests/onboarding-presentation.test.ts index 950c09dd..c621dec5 100644 --- a/frontend/tests/onboarding-presentation.test.ts +++ b/frontend/tests/onboarding-presentation.test.ts @@ -1,13 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { readFileSync } from "node:fs"; import { createOnboardingFallbackGreeting, createStartGreeting, + createStartGreetingParts, isCurrentOnboardingRequest, onboardingProfileFingerprint, onboardingRequestIdentity, } from "../src/lib/onboarding-client.ts"; -import { createStarterPrompt, starterPromptVariants } from "../src/lib/starter-prompt.ts"; const completeProfile = { name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", @@ -35,20 +36,40 @@ test("rejects stale A presentation and derives terminal and fallback greetings f assert.equal(fallbackGreeting, "周宁,从你此刻最关心的问题开始吧。"); }); -test("the starter heading varies per visit and stays inside the pool", () => { - // Given: a pool of distinct openings rather than one fixed line. - assert.ok(starterPromptVariants.length >= 5); - assert.equal(new Set(starterPromptVariants).size, starterPromptVariants.length); +test("the starter hero splits one time-aware greeting instead of drawing from a separate pool", () => { + // Given: the same daypart variant, asked for as parts and as one line. + const noon = new Date("2026-07-19T12:00:00+08:00"); + const parts = createStartGreetingParts("周宁", noon, 0); + const whole = createStartGreeting("周宁", noon, 0); - // Then: every selection lands on a real variant, including the extremes. - assert.equal(createStarterPrompt(0), starterPromptVariants[0]); - assert.equal(createStarterPrompt(1), starterPromptVariants.at(-1)); - assert.equal(createStarterPrompt(0.999999), starterPromptVariants.at(-1)); + // Then: the salutation carries the name and the question carries no name, and they recompose exactly. + assert.equal(parts.salutation, "中午好,周宁。"); + assert.equal(parts.question, "现在最想理清哪件事?"); + assert.equal(`${parts.salutation}${parts.question}`, whole); + assert.doesNotMatch(parts.question, /周宁/); - // And: the whole pool is reachable, so the heading is not one line in disguise. - const reached = new Set( - Array.from({ length: starterPromptVariants.length }, (_, index) => - createStarterPrompt(index / starterPromptVariants.length)), - ); - assert.equal(reached.size, starterPromptVariants.length); + // And: the extremes stay inside the variant list rather than falling off the end. + assert.equal(createStartGreetingParts("周宁", noon, 1).question, "事业、关系或选择,想先聊哪个?"); + assert.equal(createStartGreetingParts("周宁", noon, 0.999999).question, "事业、关系或选择,想先聊哪个?"); + + // And: every daypart is reachable, so the heading is not one fixed line in disguise. + const questions = new Set([0, 8, 12, 16, 20, 23].flatMap((hour) => + [0, 1 / 3, 2 / 3].map((selection) => + createStartGreetingParts("周宁", new Date(2026, 6, 19, hour), selection).question))); + assert.ok(questions.size >= 12); +}); + +test("the starter hero shows the Agent greeting rather than discarding it", () => { + const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + + // Given: the hero heading is the question half of the time-aware greeting. + assert.match(source, /

\{starterGreeting\.salutation\}<\/p>/); + assert.match(source, /

\{starterGreeting\.question\}<\/h1>/); + + // Then: the served onboarding greeting reaches the hero note and is never overwritten locally. + assert.match(source, /starter-hero-note">\{onboarding\?\.greeting/); + assert.doesNotMatch(source, /greeting: createStartGreeting\(/); + + // And: the separate static heading pool is gone for good. + assert.doesNotMatch(source, /starterPrompt|createStarterPrompt|greetingForHour/); });