From 346ec02fd2cab9f6b62e4eb3d68854fe4c41f18a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 23:54:27 +0800 Subject: [PATCH] fix: verify onboarding cache ownership end to end --- .../sdd/onboarding-cache-fix-report.md | 113 ++++++++ frontend/src/app/api/onboarding/route.ts | 264 +++++------------- frontend/src/lib/onboarding-payload.ts | 39 +++ frontend/src/lib/onboarding-post.ts | 200 +++++++++++++ .../birth-time-candidate-completion.test.ts | 22 ++ .../tests/onboarding-cache-policy.test.ts | 70 +++++ frontend/tests/onboarding-route-fake.ts | 87 ++++++ frontend/tests/onboarding-route.test.ts | 172 ++++++++++++ tests/test_agent_chat_contract.py | 31 +- 9 files changed, 802 insertions(+), 196 deletions(-) create mode 100644 frontend/src/lib/onboarding-payload.ts create mode 100644 frontend/src/lib/onboarding-post.ts create mode 100644 frontend/tests/onboarding-route-fake.ts create mode 100644 frontend/tests/onboarding-route.test.ts diff --git a/.superpowers/sdd/onboarding-cache-fix-report.md b/.superpowers/sdd/onboarding-cache-fix-report.md index 1ff6448b..b3331611 100644 --- a/.superpowers/sdd/onboarding-cache-fix-report.md +++ b/.superpowers/sdd/onboarding-cache-fix-report.md @@ -150,3 +150,116 @@ modified `.superpowers/sdd/task-1-report.md` and untracked `.omo/` tree remained - The programming skill's standalone no-excuse checker could not resolve its own `typescript` dependency from the external skill cache. Changed-file ESLint, direct pattern audit, full tests, and the production TypeScript build were run instead. + +--- + +## Integration-review follow-up + +### Outcome + +- Status: `DONE_WITH_CONCERNS` +- Follow-up base SHA: `e13d595d6b3130ee20647ab95b0be3e0af8bdcac` +- Follow-up commit: `fix: verify onboarding cache ownership end to end` +- Resulting follow-up SHA: reported in the task handoff because a Git commit cannot embed its own + resulting hash without changing that hash. + +`POST` now delegates to the injectable `createOnboardingPost` handler. Its stateful repository seam +executes load, version-and-timestamp claim CAS, deterministic generation, and pending-version +completion CAS as one observable HTTP workflow. The production adapter retains the real Supabase +PostgREST filters; the test fake models the same mutable single-row compare-and-set behavior. + +Completion now calls `select("id").maybeSingle()` and observes the returned row. A lost pending +identity returns the safe provisional `pending` response, never the stale generated Agent payload. +Database errors retain the prior warning and terminal fallback/Agent response behavior. + +### RED/GREEN evidence + +Initial route-integration RED: + +```text +/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-route.test.ts +``` + +Result before the injectable handler existed: exit 1, `ERR_MODULE_NOT_FOUND` for +`src/lib/onboarding-post.ts`; 0 passed, 1 failed. + +The first GREEN exercised two concurrent calls: A claimed and blocked in deterministic generation, +the fake profile changed to B, B claimed and completed, and A then lost its exact pending-version +completion. The handler returned B with `source=agent`, returned A with `source=pending`, and the +repository retained B's payload. + +Regression-sensitivity proof temporarily removed the completion result branch and returned the +generated payload unconditionally. The focused stale test failed with actual `agent`, expected +`pending`. Restoring the ownership-result branch made the same test pass 1/1. + +The stale root contract RED used the available repository environment: + +```text +/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_agent_chat_contract.py +``` + +Result before updating the contract: exit 1; 1 failed and 1 passed. The old contract still expected +the onboarding fetch in `page.tsx` and later expected removed inline version logic. The updated +contract follows the client/factory/cache modules and pins the production version CAS, timestamp +CAS, pending-version completion CAS, completion-row observation, SHA-256 identity, and default POST +delegation. Final result: 2 passed. + +### Expanded matrices + +- Route integration: ready A to B, active-pending A to B, stale A completion after B wins, observed + version race, and observed timestamp race. +- Cache policy: each of the eight selected profile inputs independently changes ready and pending + identities; TTL minus one remains pending while exact TTL reclaims; null and invalid generated + timestamps reclaim. +- Candidate completion: missing owner, empty owner, and legal `candidate_saved` compatibility were + added without changing confidence/status policy. + +### Follow-up verification + +Focused frontend tests: + +```text +/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-route.test.ts tests/onboarding-cache-policy.test.ts tests/birth-time-candidate-completion.test.ts +``` + +Result: exit 0; 31 passed, 0 failed. + +Full frontend suite: + +```text +/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts +``` + +Result: exit 0; 455 passed, 0 failed. + +Changed-file ESLint: exit 0 with zero diagnostics across the route, cache/payload/handler modules, +candidate modules, stateful fake, and focused tests. + +Affected Python contract: 2 passed. Changed-file Ruff initially found one extra blank line at the +existing import boundary; it was removed before final verification. + +Production build: + +```text +NEXT_PUBLIC_SUPABASE_URL=https://ci-placeholder.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=ci-placeholder PATH=/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH ./node_modules/.bin/next build --webpack +``` + +Result: exit 0; compiled in 5.9 seconds, completed TypeScript in 10.3 seconds, and generated 22/22 +pages. `/api/onboarding` remains a dynamic route. + +Direct `tsc --noEmit` still reports only the same eight unrelated ES2018 regexp-flag diagnostics; +no follow-up file is reported. Pure LOC is 80 (production adapter), 184 (handler), 33 (payload), 82 +(cache policy), 141 (route integration test), 75 (stateful fake), 165 (policy test), 194 (candidate +test), and 95 (root contract): every touched file remains below 200 pure LOC. + +### Follow-up concerns + +- The same pre-work Python 3.9/pytest and remote-visibility environment blockers remain; no remote + synchronization is claimed. +- Default Turbopack still rejects the worktree's external dependency symlink. The complete webpack + production build with repository-standard CI placeholders passed. + +Staged follow-up audit ran `git diff --cached --check`, `--name-status`, and `--stat`. The check +passed, and the staged set contained exactly nine follow-up paths: this report, the production +route adapter, handler, payload boundary, route fake/test, policy test, candidate test, and root CI +contract. Unrelated `.superpowers/sdd/task-1-report.md` and `.omo/` remained unstaged. diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index 16941986..568dde57 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -1,10 +1,7 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; import { - createOnboardingCacheIdentity, - createOnboardingCompletionTransition, - decideOnboardingCache, -} from "@/lib/onboarding-cache-policy"; + createOnboardingPost, + type OnboardingProfileRepository, +} from "@/lib/onboarding-post"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getOnboardingAgent } from "@/mastra"; @@ -13,189 +10,74 @@ import { defaultLanguageModel } from "@/mastra/model"; export const runtime = "nodejs"; export const maxDuration = 30; -const onboardingSchema = z.object({ - greeting: z.string().trim().min(8).max(180), - suggestions: z.tuple([ - z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }), - z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }), - z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }), - ]), +function createProfileRepository( + admin: ReturnType, +): OnboardingProfileRepository { + return { + async loadProfile(userId) { + return admin + .from("profiles") + .select("id,name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at") + .eq("id", userId) + .maybeSingle(); + }, + async claimProfile(command) { + let claim = admin + .from("profiles") + .update({ + onboarding_version: command.pendingVersion, + onboarding_generated_at: command.claimedAt, + }) + .eq("id", command.userId); + claim = command.expectedVersion === null + ? claim.is("onboarding_version", null) + : claim.eq("onboarding_version", command.expectedVersion); + claim = command.expectedGeneratedAt === null + ? claim.is("onboarding_generated_at", null) + : claim.eq("onboarding_generated_at", command.expectedGeneratedAt); + return claim.select("id").maybeSingle(); + }, + async completeProfile(command) { + return admin + .from("profiles") + .update({ + onboarding_payload: command.payload, + onboarding_version: command.readyVersion, + onboarding_generated_at: command.generatedAt, + }) + .eq("id", command.userId) + .eq("onboarding_version", command.expectedPendingVersion) + .select("id") + .maybeSingle(); + }, + }; +} + +export const POST = createOnboardingPost({ + openSession: async () => { + const supabase = await createServerSupabaseClient(); + const admin = createAdminSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + return { + userId: user?.id ?? null, + authError: Boolean(error), + repository: createProfileRepository(admin), + }; + }, + generateText: async (name) => { + const model = defaultLanguageModel(); + if (!model) return null; + const result = await getOnboardingAgent(model).generate([ + { + role: "user", + content: [ + name ? `用户称呼:${name.slice(0, 80)}` : "用户未填写称呼。", + "请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。", + ].join("\n"), + }, + ]); + return result.text; + }, + now: () => new Date(), + warn: (message, detail) => console.warn(message, detail), }); - -type OnboardingPayload = z.infer; - -const fallbackPayload: OnboardingPayload = { - greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。", - suggestions: [ - { theme: "career", text: "我的事业优势更适合怎样发挥?" }, - { theme: "marriage", text: "我在关系里容易重复什么模式?" }, - { theme: "timing", text: "未来一年有哪些阶段值得提前准备?" }, - ], -}; - -function parseJsonObject(text: string) { - const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); - const start = normalized.indexOf("{"); - const end = normalized.lastIndexOf("}"); - if (start < 0 || end <= start) throw new Error("onboarding_json_missing"); - return JSON.parse(normalized.slice(start, end + 1)); -} - -function hasCompleteBirthProfile(profile: Record) { - return Boolean( - profile.name - && profile.birth_date - && (profile.active_birth_time || profile.birth_time) - && (profile.birth_time_status === "confirmed" - || profile.birth_time_status === "candidate" - || (!profile.birth_time_status && profile.birth_time)) - && profile.country_code - && profile.province_code - && profile.city_code, - ); -} - -export async function POST() { - let supabase: Awaited>; - let admin: ReturnType; - try { - supabase = await createServerSupabaseClient(); - admin = createAdminSupabaseClient(); - } catch { - return NextResponse.json( - { error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" }, - { status: 503 }, - ); - } - - const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (authError || !user) { - return NextResponse.json( - { error: "请先登录", message: "登录后才能准备初始问题。" }, - { status: 401 }, - ); - } - - const { data: profile, error: profileError } = await admin - .from("profiles") - .select("name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at") - .eq("id", user.id) - .maybeSingle(); - - if (profileError || !profile) { - return NextResponse.json( - { error: "无法读取用户档案", message: profileError?.message || "请重新登录后再试。" }, - { status: 503 }, - ); - } - - if (!hasCompleteBirthProfile(profile)) { - return NextResponse.json( - { error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" }, - { status: 409 }, - ); - } - - const identity = createOnboardingCacheIdentity({ - name: profile.name, - birthDate: profile.birth_date, - birthTime: profile.birth_time, - activeBirthTime: profile.active_birth_time, - birthTimeStatus: profile.birth_time_status, - countryCode: profile.country_code, - provinceCode: profile.province_code, - cityCode: profile.city_code, - }); - const cached = onboardingSchema.safeParse(profile.onboarding_payload); - const generatedAtMs = typeof profile.onboarding_generated_at === "string" - ? Date.parse(profile.onboarding_generated_at) - : 0; - const cacheDecision = decideOnboardingCache({ - identity, - observedVersion: profile.onboarding_version, - generatedAtMs, - nowMs: Date.now(), - cachedPayload: cached.success ? cached.data : null, - }); - - switch (cacheDecision.kind) { - case "ready": - return NextResponse.json({ ...cacheDecision.payload, source: "cache" }); - case "pending": - return NextResponse.json({ ...fallbackPayload, source: "pending" }); - case "claim": - break; - default: { - const exhaustiveDecision: never = cacheDecision; - throw exhaustiveDecision; - } - } - - const claimTime = new Date().toISOString(); - let claim = admin - .from("profiles") - .update({ - onboarding_version: cacheDecision.pendingVersion, - onboarding_generated_at: claimTime, - }) - .eq("id", user.id); - claim = cacheDecision.expectedVersion === null - ? claim.is("onboarding_version", null) - : claim.eq("onboarding_version", cacheDecision.expectedVersion); - claim = profile.onboarding_generated_at === null - ? claim.is("onboarding_generated_at", null) - : claim.eq("onboarding_generated_at", profile.onboarding_generated_at); - const { data: claimedProfile, error: claimError } = await claim.select("id").maybeSingle(); - if (claimError) { - return NextResponse.json( - { error: "暂时无法准备初始问题", message: claimError.message }, - { status: 503 }, - ); - } - if (!claimedProfile) { - return NextResponse.json({ ...fallbackPayload, source: "pending" }); - } - - let payload = fallbackPayload; - let source: "agent" | "fallback" = "fallback"; - - const onboardingModel = defaultLanguageModel(); - if (onboardingModel) { - try { - const result = await getOnboardingAgent(onboardingModel).generate([ - { - role: "user", - content: [ - profile.name ? `用户称呼:${String(profile.name).slice(0, 80)}` : "用户未填写称呼。", - "请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。", - ].join("\n"), - }, - ]); - const parsed = onboardingSchema.safeParse(parseJsonObject(result.text)); - if (parsed.success) { - payload = parsed.data; - source = "agent"; - } - } catch (error) { - const message = error instanceof Error ? error.message : "unknown error"; - console.warn("[onboarding] agent generation failed; using safe fallback", message); - } - } - - const completionTransition = createOnboardingCompletionTransition(identity); - const { error: cacheError } = await admin - .from("profiles") - .update({ - onboarding_payload: payload, - onboarding_version: completionTransition.readyVersion, - onboarding_generated_at: new Date().toISOString(), - }) - .eq("id", user.id) - .eq("onboarding_version", completionTransition.expectedVersion); - - if (cacheError) { - console.warn("[onboarding] unable to cache generated content", cacheError.message); - } - - return NextResponse.json({ ...payload, source }); -} diff --git a/frontend/src/lib/onboarding-payload.ts b/frontend/src/lib/onboarding-payload.ts new file mode 100644 index 00000000..eea1b3c5 --- /dev/null +++ b/frontend/src/lib/onboarding-payload.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +const onboardingSchema = z.object({ + greeting: z.string().trim().min(8).max(180), + suggestions: z.tuple([ + z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }), + z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }), + z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }), + ]), +}); + +export type OnboardingPayload = z.infer; + +export const fallbackOnboardingPayload: OnboardingPayload = { + greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。", + suggestions: [ + { theme: "career", text: "我的事业优势更适合怎样发挥?" }, + { theme: "marriage", text: "我在关系里容易重复什么模式?" }, + { theme: "timing", text: "未来一年有哪些阶段值得提前准备?" }, + ], +}; + +class OnboardingJsonError extends Error { + readonly name = "OnboardingJsonError"; +} + +export function parseOnboardingPayload(value: unknown): OnboardingPayload | null { + const parsed = onboardingSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +export function parseOnboardingText(text: string): OnboardingPayload | null { + const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); + const start = normalized.indexOf("{"); + const end = normalized.lastIndexOf("}"); + if (start < 0 || end <= start) throw new OnboardingJsonError("onboarding_json_missing"); + const parsed: unknown = JSON.parse(normalized.slice(start, end + 1)); + return parseOnboardingPayload(parsed); +} diff --git a/frontend/src/lib/onboarding-post.ts b/frontend/src/lib/onboarding-post.ts new file mode 100644 index 00000000..9627506c --- /dev/null +++ b/frontend/src/lib/onboarding-post.ts @@ -0,0 +1,200 @@ +import { + createOnboardingCacheIdentity, + createOnboardingCompletionTransition, + decideOnboardingCache, +} from "./onboarding-cache-policy.ts"; +import { + fallbackOnboardingPayload, + type OnboardingPayload, + parseOnboardingPayload, + parseOnboardingText, +} from "./onboarding-payload.ts"; + +export type OnboardingProfileRow = { + readonly id: string; + readonly name: string | null; + readonly birth_date: string | null; + readonly birth_time: string | null; + readonly active_birth_time: string | null; + readonly birth_time_status: string | null; + readonly country_code: string | null; + readonly province_code: string | null; + readonly city_code: string | null; + readonly onboarding_payload: unknown; + readonly onboarding_version: string | null; + readonly onboarding_generated_at: string | null; +}; + +type RepositoryError = { readonly message: string }; +type RepositoryResult = { + readonly data: Value | null; + readonly error: RepositoryError | null; +}; + +export type OnboardingClaimCommand = { + readonly userId: string; + readonly expectedVersion: string | null; + readonly expectedGeneratedAt: string | null; + readonly pendingVersion: string; + readonly claimedAt: string; +}; + +export type OnboardingCompletionCommand = { + readonly userId: string; + readonly expectedPendingVersion: string; + readonly readyVersion: string; + readonly payload: OnboardingPayload; + readonly generatedAt: string; +}; + +export interface OnboardingProfileRepository { + loadProfile(userId: string): Promise>; + claimProfile(command: OnboardingClaimCommand): Promise>; + completeProfile(command: OnboardingCompletionCommand): Promise>; +} + +type OnboardingSession = { + readonly userId: string | null; + readonly authError: boolean; + readonly repository: OnboardingProfileRepository; +}; + +type OnboardingPostDependencies = { + readonly openSession: () => Promise; + readonly generateText: (name: string) => Promise; + readonly now: () => Date; + readonly warn: (message: string, detail: string) => void; +}; + +function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean { + return Boolean( + profile.name + && profile.birth_date + && (profile.active_birth_time || profile.birth_time) + && (profile.birth_time_status === "confirmed" + || profile.birth_time_status === "candidate" + || (!profile.birth_time_status && profile.birth_time)) + && profile.country_code + && profile.province_code + && profile.city_code, + ); +} + +export function createOnboardingPost(dependencies: OnboardingPostDependencies): () => Promise { + return async function onboardingPost(): Promise { + let session: OnboardingSession; + try { + session = await dependencies.openSession(); + } catch { // no-excuse-ok: catch -- route boundary converts missing configuration. + return Response.json( + { error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" }, + { status: 503 }, + ); + } + + if (session.authError || !session.userId) { + return Response.json( + { error: "请先登录", message: "登录后才能准备初始问题。" }, + { status: 401 }, + ); + } + + const loaded = await session.repository.loadProfile(session.userId); + if (loaded.error || !loaded.data) { + return Response.json( + { error: "无法读取用户档案", message: loaded.error?.message || "请重新登录后再试。" }, + { status: 503 }, + ); + } + const profile = loaded.data; + if (!hasCompleteBirthProfile(profile)) { + return Response.json( + { error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" }, + { status: 409 }, + ); + } + + const identity = createOnboardingCacheIdentity({ + name: profile.name, + birthDate: profile.birth_date, + birthTime: profile.birth_time, + activeBirthTime: profile.active_birth_time, + birthTimeStatus: profile.birth_time_status, + countryCode: profile.country_code, + provinceCode: profile.province_code, + cityCode: profile.city_code, + }); + const cachedPayload = parseOnboardingPayload(profile.onboarding_payload); + const generatedAtMs = profile.onboarding_generated_at === null + ? Number.NaN + : Date.parse(profile.onboarding_generated_at); + const now = dependencies.now(); + const decision = decideOnboardingCache({ + identity, + observedVersion: profile.onboarding_version, + generatedAtMs, + nowMs: now.getTime(), + cachedPayload, + }); + + switch (decision.kind) { + case "ready": + return Response.json({ ...decision.payload, source: "cache" }); + case "pending": + return Response.json({ ...fallbackOnboardingPayload, source: "pending" }); + case "claim": + break; + default: { + const exhaustiveDecision: never = decision; + throw exhaustiveDecision; + } + } + + const claim = await session.repository.claimProfile({ + userId: session.userId, + expectedVersion: decision.expectedVersion, + expectedGeneratedAt: profile.onboarding_generated_at, + pendingVersion: decision.pendingVersion, + claimedAt: now.toISOString(), + }); + if (claim.error) { + return Response.json( + { error: "暂时无法准备初始问题", message: claim.error.message }, + { status: 503 }, + ); + } + if (!claim.data) return Response.json({ ...fallbackOnboardingPayload, source: "pending" }); + + let payload = fallbackOnboardingPayload; + let source: "agent" | "fallback" = "fallback"; + try { + const text = await dependencies.generateText(profile.name ?? ""); + const parsed = text === null ? null : parseOnboardingText(text); + if (parsed) { + payload = parsed; + source = "agent"; + } + } catch (error) { // no-excuse-ok: catch -- generation failure intentionally uses safe fallback. + dependencies.warn( + "[onboarding] agent generation failed; using safe fallback", + error instanceof Error ? error.message : "unknown error", + ); + } + + const completion = createOnboardingCompletionTransition(identity); + const completed = await session.repository.completeProfile({ + userId: session.userId, + expectedPendingVersion: completion.expectedVersion, + readyVersion: completion.readyVersion, + payload, + generatedAt: dependencies.now().toISOString(), + }); + if (completed.error) { + dependencies.warn("[onboarding] unable to cache generated content", completed.error.message); + return Response.json({ ...payload, source }); + } + return completed.data + ? Response.json({ ...payload, source }) + : Response.json({ ...fallbackOnboardingPayload, source: "pending" }); + }; +} diff --git a/frontend/tests/birth-time-candidate-completion.test.ts b/frontend/tests/birth-time-candidate-completion.test.ts index 1adaa7c8..0e69ecf5 100644 --- a/frontend/tests/birth-time-candidate-completion.test.ts +++ b/frontend/tests/birth-time-candidate-completion.test.ts @@ -59,6 +59,18 @@ test("accepts a matching low-confidence result from the rectifying state", () => }), "04:53"); }); +test("accepts a persisted candidate-saved compatibility action", () => { + assert.equal(candidateWorkingTime({ + ...terminalCase, + turn_state: { + nextAction: { + kind: "candidate_saved", + resultId: terminalCase.candidate_result_id, + }, + }, + }, completionRequest), "04:53"); +}); + test("does not accept a medium terminal action from the rectifying state", () => { assert.equal(candidateWorkingTime({ ...lowTerminalCase, @@ -94,6 +106,16 @@ const rejectedCompletions = [ stored: terminalCase, request: { ...completionRequest, userId: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" }, }, + { + name: "missing case owner", + stored: { ...terminalCase, user_id: null }, + request: completionRequest, + }, + { + name: "empty case owner", + stored: { ...terminalCase, user_id: "" }, + request: completionRequest, + }, { name: "wrong case ID", stored: terminalCase, diff --git a/frontend/tests/onboarding-cache-policy.test.ts b/frontend/tests/onboarding-cache-policy.test.ts index d5ed6914..926f87e4 100644 --- a/frontend/tests/onboarding-cache-policy.test.ts +++ b/frontend/tests/onboarding-cache-policy.test.ts @@ -4,6 +4,7 @@ import { createOnboardingCacheIdentity, createOnboardingCompletionTransition, decideOnboardingCache, + ONBOARDING_CLAIM_TTL_MS, } from "../src/lib/onboarding-cache-policy.ts"; const profileA = { @@ -132,3 +133,72 @@ test("invalid ready content and expired pending claims are reclaimed", () => { }); } }); + +test("every selected profile input participates in the cache identity", () => { + // Given: the exact eight profile inputs used by onboarding generation/completeness. + const baseIdentity = createOnboardingCacheIdentity(profileA); + const mutations = [ + { field: "name", profile: { ...profileA, name: "周宁" } }, + { field: "birthDate", profile: { ...profileA, birthDate: "1991-06-15" } }, + { field: "birthTime", profile: { ...profileA, birthTime: "12:31" } }, + { field: "activeBirthTime", profile: { ...profileA, activeBirthTime: "12:45" } }, + { field: "birthTimeStatus", profile: { ...profileA, birthTimeStatus: "candidate" } }, + { field: "countryCode", profile: { ...profileA, countryCode: "TW" } }, + { field: "provinceCode", profile: { ...profileA, provinceCode: "310000" } }, + { field: "cityCode", profile: { ...profileA, cityCode: "310100" } }, + ] as const; + + // When/Then: mutating any one input changes both ready and pending identities. + for (const mutation of mutations) { + const changed = createOnboardingCacheIdentity(mutation.profile); + assert.notEqual(changed.readyVersion, baseIdentity.readyVersion, mutation.field); + assert.notEqual(changed.pendingVersion, baseIdentity.pendingVersion, mutation.field); + } +}); + +test("pending claim TTL is active through TTL minus one and reclaimable at TTL", () => { + // Given: the current profile owns the pending identity at a fixed time. + const identity = createOnboardingCacheIdentity(profileA); + const nowMs = Date.parse("2026-07-19T10:03:00.000Z"); + + // When/Then: the exact boundary preserves the existing strict-less-than policy. + assert.deepEqual(decideOnboardingCache({ + identity, + observedVersion: identity.pendingVersion, + generatedAtMs: nowMs - ONBOARDING_CLAIM_TTL_MS + 1, + nowMs, + cachedPayload: null, + }), { kind: "pending" }); + assert.deepEqual(decideOnboardingCache({ + identity, + observedVersion: identity.pendingVersion, + generatedAtMs: nowMs - ONBOARDING_CLAIM_TTL_MS, + nowMs, + cachedPayload: null, + }), { + kind: "claim", + expectedVersion: identity.pendingVersion, + pendingVersion: identity.pendingVersion, + }); +}); + +test("pending claims with null or invalid generation timestamps are reclaimed", () => { + // Given: null and invalid timestamps have both been normalized to non-finite milliseconds. + const identity = createOnboardingCacheIdentity(profileA); + const observations = [Number.NaN, Date.parse("not-a-timestamp")]; + + // When/Then: neither timestamp can keep a pending claim active. + for (const generatedAtMs of observations) { + assert.deepEqual(decideOnboardingCache({ + identity, + observedVersion: identity.pendingVersion, + generatedAtMs, + nowMs: Date.parse("2026-07-19T10:03:00.000Z"), + cachedPayload: null, + }), { + kind: "claim", + expectedVersion: identity.pendingVersion, + pendingVersion: identity.pendingVersion, + }); + } +}); diff --git a/frontend/tests/onboarding-route-fake.ts b/frontend/tests/onboarding-route-fake.ts new file mode 100644 index 00000000..b8c2c522 --- /dev/null +++ b/frontend/tests/onboarding-route-fake.ts @@ -0,0 +1,87 @@ +import type { + OnboardingClaimCommand, + OnboardingCompletionCommand, + OnboardingProfileRepository, + OnboardingProfileRow, +} from "../src/lib/onboarding-post.ts"; + +type ProfilePatch = Partial>; + +export class StatefulOnboardingProfileRepository implements OnboardingProfileRepository { + private row: OnboardingProfileRow; + private nextClaimInterference: ProfilePatch | null = null; + + constructor(row: OnboardingProfileRow) { + this.row = structuredClone(row); + } + + setProfile(patch: ProfilePatch): void { + this.row = { ...this.row, ...structuredClone(patch) }; + } + + interfereBeforeNextClaim(patch: ProfilePatch): void { + this.nextClaimInterference = structuredClone(patch); + } + + snapshot(): OnboardingProfileRow { + return structuredClone(this.row); + } + + async loadProfile(userId: string) { + return this.row.id === userId + ? { data: this.snapshot(), error: null } + : { data: null, error: null }; + } + + async claimProfile(command: OnboardingClaimCommand) { + if (this.nextClaimInterference) { + this.setProfile(this.nextClaimInterference); + this.nextClaimInterference = null; + } + const ownsObservedRow = this.row.id === command.userId + && this.row.onboarding_version === command.expectedVersion + && this.row.onboarding_generated_at === command.expectedGeneratedAt; + if (!ownsObservedRow) return { data: null, error: null }; + + this.row = { + ...this.row, + onboarding_version: command.pendingVersion, + onboarding_generated_at: command.claimedAt, + }; + return { data: { id: this.row.id }, error: null }; + } + + async completeProfile(command: OnboardingCompletionCommand) { + const ownsPendingRow = this.row.id === command.userId + && this.row.onboarding_version === command.expectedPendingVersion; + if (!ownsPendingRow) return { data: null, error: null }; + + this.row = { + ...this.row, + onboarding_payload: structuredClone(command.payload), + onboarding_version: command.readyVersion, + onboarding_generated_at: command.generatedAt, + }; + return { data: { id: this.row.id }, error: null }; + } +} + +export function completeProfileRow( + patch: ProfilePatch = {}, +): OnboardingProfileRow { + return { + id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a", + name: "林遥", + birth_date: "1990-06-15", + birth_time: "12:30", + active_birth_time: "12:30", + birth_time_status: "confirmed", + country_code: "CN", + province_code: "110000", + city_code: "110100", + onboarding_payload: null, + onboarding_version: null, + onboarding_generated_at: null, + ...structuredClone(patch), + }; +} diff --git a/frontend/tests/onboarding-route.test.ts b/frontend/tests/onboarding-route.test.ts new file mode 100644 index 00000000..29e87636 --- /dev/null +++ b/frontend/tests/onboarding-route.test.ts @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { z } from "zod"; +import { createOnboardingCacheIdentity } from "../src/lib/onboarding-cache-policy.ts"; +import { createOnboardingPost } from "../src/lib/onboarding-post.ts"; +import { + completeProfileRow, + StatefulOnboardingProfileRepository, +} from "./onboarding-route-fake.ts"; + +const payloadA = { + greeting: "林遥,欢迎开始今天的咨询。", + suggestions: [ + { theme: "career", text: "林遥的事业方向是什么?" }, + { theme: "marriage", text: "林遥的关系模式是什么?" }, + { theme: "timing", text: "林遥何时适合采取行动?" }, + ], +} as const; + +const payloadB = { + greeting: "周宁,欢迎开始今天的咨询。", + suggestions: [ + { theme: "career", text: "周宁的事业方向是什么?" }, + { theme: "marriage", text: "周宁的关系模式是什么?" }, + { theme: "timing", text: "周宁何时适合采取行动?" }, + ], +} as const; + +function generatedText(payload: typeof payloadA | typeof payloadB): string { + return JSON.stringify(payload); +} + +function deferred() { + let settle: (value: Value) => void = () => undefined; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { promise, resolve: settle } as const; +} + +const responseBodySchema = z.object({ + greeting: z.string(), + suggestions: z.array(z.object({ theme: z.string(), text: z.string() })), + source: z.enum(["agent", "cache", "fallback", "pending"]), +}); + +async function responseBody(response: Response): Promise> { + return responseBodySchema.parse(await response.json()); +} + +function createPost( + repository: StatefulOnboardingProfileRepository, + generateText: (name: string) => Promise, +) { + return createOnboardingPost({ + openSession: async () => ({ + userId: repository.snapshot().id, + authError: false, + repository, + }), + generateText, + now: () => new Date("2026-07-19T10:00:00.000Z"), + warn: () => undefined, + }); +} + +test("stale A generation returns pending after profile B replaces its claim", async () => { + // Given: A owns a claim whose generation remains in flight. + const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); + const generationA = deferred(); + const generationAStarted = deferred(); + const post = createPost( + repository, + async (name) => { + if (name === "林遥") { + generationAStarted.resolve(); + return generationA.promise; + } + return generatedText(payloadB); + }, + ); + const responseA = post(); + await generationAStarted.promise; + + // When: the persisted profile changes to B, B claims/completes, then A finishes. + repository.setProfile({ name: "周宁" }); + const bodyB = await responseBody(await post()); + generationA.resolve(generatedText(payloadA)); + const bodyA = await responseBody(await responseA); + + // Then: B remains cached and A is provisional, never a stale terminal payload. + assert.deepEqual(bodyB, { ...payloadB, source: "agent" }); + assert.equal(bodyA.source, "pending"); + assert.doesNotMatch(JSON.stringify(bodyA), /林遥/); + assert.deepEqual(repository.snapshot().onboarding_payload, payloadB); +}); + +test("profile B replaces profile A ready cache instead of returning A content", async () => { + // Given: A has a ready cache, then the persisted profile changes to B. + const identityA = createOnboardingCacheIdentity({ + name: "林遥", birthDate: "1990-06-15", birthTime: "12:30", activeBirthTime: "12:30", + birthTimeStatus: "confirmed", countryCode: "CN", provinceCode: "110000", cityCode: "110100", + }); + const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ + onboarding_payload: payloadA, + onboarding_version: identityA.readyVersion, + onboarding_generated_at: "2026-07-19T09:59:00.000Z", + })); + repository.setProfile({ name: "周宁" }); + const post = createPost(repository, async () => generatedText(payloadB)); + + // When: B requests onboarding through the real handler seam. + const body = await responseBody(await post()); + + // Then: B is generated and cached; A's ready payload is never returned. + assert.deepEqual(body, { ...payloadB, source: "agent" }); + assert.deepEqual(repository.snapshot().onboarding_payload, payloadB); +}); + +test("profile B replaces profile A active pending claim instead of waiting on A", async () => { + // Given: A has a fresh pending claim, then B changes the active birth time. + const profile = completeProfileRow(); + const identityA = createOnboardingCacheIdentity({ + name: profile.name, birthDate: profile.birth_date, birthTime: profile.birth_time, + activeBirthTime: profile.active_birth_time, birthTimeStatus: profile.birth_time_status, + countryCode: profile.country_code, provinceCode: profile.province_code, cityCode: profile.city_code, + }); + const repository = new StatefulOnboardingProfileRepository({ + ...profile, + onboarding_version: identityA.pendingVersion, + onboarding_generated_at: "2026-07-19T09:59:30.000Z", + }); + repository.setProfile({ name: "周宁", active_birth_time: "12:45" }); + let generatedFor = ""; + const post = createPost(repository, async (name) => { + generatedFor = name; + return generatedText(payloadB); + }); + + // When: B requests onboarding within A's TTL. + const body = await responseBody(await post()); + + // Then: B claims and completes immediately rather than receiving pending for A. + assert.equal(generatedFor, "周宁"); + assert.deepEqual(body, { ...payloadB, source: "agent" }); +}); + +for (const interference of [ + { name: "observed version", patch: { onboarding_version: "concurrent-version" } }, + { name: "observed timestamp", patch: { onboarding_generated_at: "2026-07-19T09:58:00.000Z" } }, +] as const) { + test(`claim loses when a concurrent writer changes the ${interference.name}`, async () => { + // Given: another writer changes one observed CAS field just before the claim. + const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ + onboarding_version: "legacy-ready", + onboarding_generated_at: "2026-07-19T09:59:00.000Z", + })); + repository.interfereBeforeNextClaim(interference.patch); + let generationCount = 0; + const post = createPost(repository, async () => { + generationCount += 1; + return generatedText(payloadA); + }); + + // When: the handler attempts its observed-row claim. + const body = await responseBody(await post()); + + // Then: compare-and-set loses provisionally and generation never starts. + assert.equal(body.source, "pending"); + assert.equal(generationCount, 0); + }); +} diff --git a/tests/test_agent_chat_contract.py b/tests/test_agent_chat_contract.py index 378db1e4..19811549 100644 --- a/tests/test_agent_chat_contract.py +++ b/tests/test_agent_chat_contract.py @@ -1,10 +1,14 @@ from pathlib import Path - ROOT = Path(__file__).resolve().parents[1] PAGE = ROOT / "frontend" / "src" / "app" / "page.tsx" AGENT = ROOT / "frontend" / "src" / "mastra" / "index.ts" ONBOARDING_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "onboarding" / "route.ts" +ONBOARDING_CLIENT = ROOT / "frontend" / "src" / "lib" / "onboarding-client.ts" +ONBOARDING_POST = ROOT / "frontend" / "src" / "lib" / "onboarding-post.ts" +ONBOARDING_CACHE_POLICY = ( + ROOT / "frontend" / "src" / "lib" / "onboarding-cache-policy.ts" +) CONSULT_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "consult" / "route.ts" MODELS_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "models" / "route.ts" MODEL_SELECTION = ROOT / "frontend" / "src" / "lib" / "consultation-model-selection.ts" @@ -24,6 +28,9 @@ def test_onboarding_and_agent_suggestion_contract() -> None: page = PAGE.read_text(encoding="utf-8") agent = AGENT.read_text(encoding="utf-8") route = ONBOARDING_ROUTE.read_text(encoding="utf-8") + onboarding_client = ONBOARDING_CLIENT.read_text(encoding="utf-8") + onboarding_post = ONBOARDING_POST.read_text(encoding="utf-8") + cache_policy = ONBOARDING_CACHE_POLICY.read_text(encoding="utf-8") consult_route = CONSULT_ROUTE.read_text(encoding="utf-8") migration = ONBOARDING_MIGRATION.read_text(encoding="utf-8") @@ -39,7 +46,7 @@ def test_onboarding_and_agent_suggestion_contract() -> None: assert " None: assert "function currentTimeContext(now = new Date())" in consult_route assert "currentTimeContext(requestTime)," in consult_route assert "中国标准时间(UTC+8)" in consult_route - assert 'profile.onboarding_version === ONBOARDING_VERSION' in route - assert "getOnboardingAgent(onboardingModel).generate" in route - assert 'source: "cache"' in route + assert "export const POST = createOnboardingPost" in route + assert 'claim.is("onboarding_version", null)' in route + assert '.eq("onboarding_version", command.expectedVersion)' in route + assert 'claim.is("onboarding_generated_at", null)' in route + assert '.eq("onboarding_generated_at", command.expectedGeneratedAt)' in route + assert ( + '.eq("onboarding_version", command.expectedPendingVersion)\n' + ' .select("id")\n' + " .maybeSingle();" + ) in route + assert "createOnboardingCacheIdentity" in onboarding_post + assert "expectedGeneratedAt: profile.onboarding_generated_at" in onboarding_post + assert "completed.data" in onboarding_post + assert 'source: "cache"' in onboarding_post + assert 'source: "pending"' in onboarding_post + assert 'createHash("sha256")' in cache_policy + assert "getOnboardingAgent(model).generate" in route assert "onboarding_payload" in migration assert "to service_role" in migration assert "to authenticated" not in migration