a6d4473ef0
The home screen renders all ten domains from the consultation registry, but the Agent only ever wrote three of them; the other seven were static registry prompts dressed up as personalized starting points. The payload now has to cover every domain in registry order, validated as a set rather than per item, so a short or misordered answer is rejected whole instead of silently leaving cards on static copy. The greeting went the other way. Nothing has rendered it since the hero note was removed, so it leaves the schema, the prompt, and the client contract rather than costing tokens for text no one reads. Ten questions take much longer to generate than three, so the route, the server generation budget, and the client request deadline all grow together, and the cache version bump forces existing payloads to be regenerated once under the new shape. Co-authored-by: Cursor <cursoragent@cursor.com>
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
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 {
|
||
fallbackOnboardingPayload,
|
||
onboardingSuggestionThemes,
|
||
parseOnboardingPayload,
|
||
} from "../src/lib/onboarding-payload.ts";
|
||
import {
|
||
completeProfileRow,
|
||
StatefulOnboardingProfileRepository,
|
||
} from "./onboarding-route-fake.ts";
|
||
|
||
// Both payloads cover every consultation domain; the marker is what tells one
|
||
// profile's generated content from the other's when checking for stale leaks.
|
||
function payloadCovering(marker: string) {
|
||
return {
|
||
suggestions: onboardingSuggestionThemes.map((theme) => ({
|
||
theme,
|
||
text: `请帮我看看${theme}方向的${marker}重点。`,
|
||
})),
|
||
};
|
||
}
|
||
|
||
const payloadA = payloadCovering("甲组");
|
||
const payloadB = payloadCovering("乙组");
|
||
|
||
function generatedText(payload: ReturnType<typeof payloadCovering>): string {
|
||
return JSON.stringify(payload);
|
||
}
|
||
|
||
function deferred<Value>() {
|
||
let settle: (value: Value) => void = () => undefined;
|
||
const promise = new Promise<Value>((resolve) => {
|
||
settle = resolve;
|
||
});
|
||
return { promise, resolve: settle } as const;
|
||
}
|
||
|
||
const responseBodySchema = z.object({
|
||
suggestions: z.array(z.object({ theme: z.string(), text: z.string() })),
|
||
source: z.enum(["agent", "cache", "fallback", "pending"]),
|
||
}).strict();
|
||
|
||
async function responseBody(response: Response): Promise<z.infer<typeof responseBodySchema>> {
|
||
return responseBodySchema.parse(await response.json());
|
||
}
|
||
|
||
function createPost(
|
||
repository: StatefulOnboardingProfileRepository,
|
||
generateText: (name: string) => Promise<string | null>,
|
||
) {
|
||
return createOnboardingPost({
|
||
openSession: async () => ({
|
||
userId: repository.snapshot().id,
|
||
authError: false,
|
||
repository,
|
||
}),
|
||
generateText,
|
||
now: () => new Date("2026-07-19T10:00:00.000Z"),
|
||
warn: () => undefined,
|
||
});
|
||
}
|
||
|
||
test("the safe fallback satisfies the payload schema it is meant to replace", () => {
|
||
// The fallback is built from the domain registry, so a registry prompt that stops
|
||
// being first-person would otherwise ship a payload the schema itself rejects.
|
||
assert.notEqual(parseOnboardingPayload(fallbackOnboardingPayload), null);
|
||
assert.deepEqual(
|
||
fallbackOnboardingPayload.suggestions.map((item) => item.theme),
|
||
[...onboardingSuggestionThemes],
|
||
);
|
||
});
|
||
|
||
test("no onboarding surface generates or transports a greeting", () => {
|
||
const instructions = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
|
||
const onboardingPrompt = instructions.slice(
|
||
instructions.indexOf("const onboardingInstructions"),
|
||
instructions.indexOf("const onboardingAgents"));
|
||
const route = readFileSync(new URL("../src/app/api/onboarding/route.ts", import.meta.url), "utf8");
|
||
const payload = readFileSync(new URL("../src/lib/onboarding-payload.ts", import.meta.url), "utf8");
|
||
const client = readFileSync(new URL("../src/lib/onboarding-client.ts", import.meta.url), "utf8");
|
||
|
||
// Given: the hero renders a locally composed salutation, so a generated greeting has
|
||
// nowhere to go and must not cost tokens.
|
||
assert.doesNotMatch(onboardingPrompt, /"greeting"/);
|
||
assert.match(onboardingPrompt, /Do not add a greeting/);
|
||
assert.doesNotMatch(payload, /greeting/);
|
||
assert.doesNotMatch(client, /greeting: (?:parsed|z\.string)/);
|
||
|
||
// And: the route names the required themes so the Agent covers the whole grid.
|
||
assert.match(route, /onboardingSuggestionThemes/);
|
||
});
|
||
|
||
test("slow Agent generation is aborted and a terminal fallback is cached before the client deadline", async () => {
|
||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
|
||
let generationAborted = false;
|
||
const dependencies = {
|
||
openSession: async () => ({
|
||
userId: repository.snapshot().id,
|
||
authError: false,
|
||
repository,
|
||
}),
|
||
generateText: (_name: string, signal?: AbortSignal) => new Promise<string | null>((_resolve, reject) => {
|
||
signal?.addEventListener("abort", () => {
|
||
generationAborted = true;
|
||
reject(signal.reason);
|
||
}, { once: true });
|
||
}),
|
||
generationTimeoutMs: 5,
|
||
now: () => new Date("2026-07-19T10:00:00.000Z"),
|
||
warn: () => undefined,
|
||
};
|
||
const deadline = new Promise<"deadline">((resolve) => {
|
||
setTimeout(() => resolve("deadline"), 50);
|
||
});
|
||
|
||
const outcome = await Promise.race([createOnboardingPost(dependencies)(), deadline]);
|
||
|
||
assert.notEqual(outcome, "deadline");
|
||
assert.ok(outcome instanceof Response);
|
||
assert.equal(generationAborted, true);
|
||
const body = await responseBody(outcome);
|
||
assert.equal(body.source, "fallback");
|
||
assert.deepEqual(repository.snapshot().onboarding_payload, { suggestions: body.suggestions });
|
||
});
|
||
|
||
|
||
test("detached Agent questions are rejected in favor of user-centered fallbacks", async () => {
|
||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
|
||
const post = createPost(repository, async () => JSON.stringify({
|
||
suggestions: onboardingSuggestionThemes.map((theme) => ({
|
||
theme,
|
||
text: `印度占星一般会从哪些因素理解${theme}?`,
|
||
})),
|
||
}));
|
||
|
||
const body = await responseBody(await post());
|
||
|
||
assert.equal(body.source, "fallback");
|
||
assert.ok(body.suggestions.every((item) => /我/.test(item.text)));
|
||
assert.ok(body.suggestions.every((item) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(item.text)));
|
||
});
|
||
|
||
test("the home receives one question per consultation domain, never a partial set", async () => {
|
||
// Given: the Agent answers with only the three themes the old contract required.
|
||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
|
||
const post = createPost(repository, async () => JSON.stringify({
|
||
suggestions: [
|
||
{ theme: "career", text: "请帮我看看事业方向。" },
|
||
{ theme: "marriage", text: "请帮我看看关系模式。" },
|
||
{ theme: "timing", text: "请帮我看看时机安排。" },
|
||
],
|
||
}));
|
||
|
||
// When: the partial answer crosses the payload boundary.
|
||
const body = await responseBody(await post());
|
||
|
||
// Then: it is rejected wholesale, and the fallback still covers every domain in order
|
||
// so no home card is left without a question.
|
||
assert.equal(body.source, "fallback");
|
||
assert.deepEqual(body.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes]);
|
||
});
|
||
|
||
test("a reordered set is rejected so questions cannot land under the wrong theme", async () => {
|
||
// Given: every domain is present, but two themes are swapped against their questions.
|
||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
|
||
const swapped = [...onboardingSuggestionThemes];
|
||
[swapped[0], swapped[1]] = [swapped[1]!, swapped[0]!];
|
||
const post = createPost(repository, async () => JSON.stringify({
|
||
suggestions: swapped.map((theme) => ({ theme, text: `请帮我看看${theme}方向的重点。` })),
|
||
}));
|
||
|
||
const body = await responseBody(await post());
|
||
|
||
assert.equal(body.source, "fallback");
|
||
assert.deepEqual(body.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes]);
|
||
});
|
||
|
||
test("PostgreSQL Date birth_date is normalized before onboarding validation", async () => {
|
||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
|
||
birth_date: new Date(1990, 5, 15),
|
||
}));
|
||
const post = createPost(repository, async () => generatedText(payloadA));
|
||
|
||
const response = await post();
|
||
const body = await responseBody(response);
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.deepEqual(body, { ...payloadA, source: "agent" });
|
||
});
|
||
|
||
test("period-only birth declaration can generate the home starter questions without a concrete minute", async () => {
|
||
const repository = new StatefulOnboardingProfileRepository({
|
||
...completeProfileRow({
|
||
birth_time: null,
|
||
active_birth_time: null,
|
||
birth_time_status: "reported",
|
||
}),
|
||
reported_birth_time: null,
|
||
birth_time_source: "period_only",
|
||
birth_time_period: "early_morning",
|
||
birth_time_clue: "家人只记得凌晨或清晨",
|
||
uncertainty_before_minutes: null,
|
||
uncertainty_after_minutes: null,
|
||
});
|
||
let generationCount = 0;
|
||
const post = createPost(repository, async () => {
|
||
generationCount += 1;
|
||
return generatedText(payloadA);
|
||
});
|
||
|
||
const response = await post();
|
||
const body = await responseBody(response);
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.equal(generationCount, 1);
|
||
assert.deepEqual(body, { ...payloadA, source: "agent" });
|
||
});
|
||
|
||
test("global place with an IANA timezone can enter home before a numeric offset is resolved", async () => {
|
||
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
|
||
name: "jesse",
|
||
birth_date: "1955-02-24",
|
||
birth_time: null,
|
||
reported_birth_time: null,
|
||
active_birth_time: null,
|
||
birth_time_source: "period_only",
|
||
birth_time_period: "evening",
|
||
birth_time_clue: "大约晚上七点左右,可能前后差四十五分钟。",
|
||
birth_time_status: null,
|
||
country_code: "US",
|
||
province_code: null,
|
||
city_code: null,
|
||
latitude: 37.7879363,
|
||
longitude: -122.4075201,
|
||
timezone_offset: null,
|
||
birth_place_label: "旧金山, 加利福尼亚州, 美国",
|
||
timezone_id: "America/Los_Angeles",
|
||
}));
|
||
let generationCount = 0;
|
||
const response = await createPost(repository, async () => {
|
||
generationCount += 1;
|
||
return generatedText(payloadA);
|
||
})();
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.equal(generationCount, 1);
|
||
});
|
||
|
||
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<string | null>();
|
||
const generationAStarted = deferred<void>();
|
||
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 instanceof Date
|
||
? profile.birth_date.toISOString().slice(0, 10)
|
||
: 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);
|
||
});
|
||
}
|