fix(consult): resolve chat subject from server-owned profile binding

This commit is contained in:
jesse-ux
2026-09-22 12:11:52 +08:00
parent 10baeb2fa8
commit 6ac61be07d
10 changed files with 1101 additions and 8 deletions
@@ -73,7 +73,10 @@ test("standard consultation resolves and settles the session-pinned model versio
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
// Former value: select("id,model_id,model_config_version,session_type,messages").
// First-round session titles need the current title, theme, and chart role.
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role,context_summary"\)/);
// 原值: select includes chart_profile_role but not chart_profile_id or chart_profile_name.
// 新值: the same select also reads chart_profile_id and chart_profile_name.
// 原因: ordinary chat resolves the subject from the stored binding, not from chart_profile_role alone.
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary"\)/);
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(consultRoute, /actualModelId: selectedModel\.id/);
assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/);
@@ -0,0 +1,512 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
ConsultationProfileTruthError,
prepareConsultationRoute,
} from "../src/lib/consultation-route-service.ts";
import {
authoritativeSessionBinding,
ConsultationSubjectError,
consultationSubjectBindingFromSession,
consultationSubjectFailureResponse,
resolveConsultationSubject,
type OwnedChartProfile,
} from "../src/lib/consultation-subject-resolver.ts";
const selfProfile = Object.freeze({
name: "Synthetic Self",
birth_date: "1990-01-02",
reported_birth_time: "08:15:00",
active_birth_time: "08:15:00",
birth_time_source: "family_exact",
birth_time_status: "reported",
birth_place_label: "Synthetic City",
latitude: 31.2,
longitude: 121.5,
timezone_offset: 8,
country_code: "CN",
province_code: "310000",
city_code: "310100",
district_code: "",
});
const otherProfile = Object.freeze({
name: "Synthetic Other",
date: "1988-03-04",
reportedTime: "09:20",
time: "",
birthTimeSource: "family_exact",
birthTimeStatus: "reported",
birthPlaceLabel: "Synthetic Harbor",
latitude: 22.3,
longitude: 114.2,
timezoneOffset: 8,
countryCode: "CN",
ayanamsa: "lahiri",
});
const clientBirth = Object.freeze({
name: "Client Name",
year: 2001,
month: 2,
day: 3,
hour: 4,
minute: 5,
city: "Client City",
lat: 1,
lon: 2,
tz: 3,
});
function ownedOther(profile: unknown = otherProfile, userId = "user-self"): OwnedChartProfile {
return { id: "chart-other", userId, role: "other", profile };
}
test("self resolves from the authoritative profile and other resolves from the owned chart profile", async () => {
let selfLoads = 0;
let otherLoads = 0;
const self = await resolveConsultationSubject({
userId: "user-self",
binding: { chartProfileId: "self", chartProfileRole: "self", chartProfileName: "Client Name" },
clientBirth,
loadSelfProfile: async (userId) => {
assert.equal(userId, "user-self");
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async () => {
otherLoads += 1;
return ownedOther();
},
});
assert.equal(selfLoads, 1);
assert.equal(otherLoads, 0);
assert.equal(self.role, "self");
assert.equal(self.name, "Synthetic Self");
assert.equal((self.profile as { birth_date: string }).birth_date, "1990-01-02");
const legacy = await resolveConsultationSubject({
userId: "user-self",
binding: { chartProfileId: null, chartProfileRole: null, chartProfileName: null },
clientBirth,
loadSelfProfile: async () => selfProfile,
loadOwnedChartProfile: async () => {
otherLoads += 1;
return ownedOther();
},
});
assert.equal(otherLoads, 0);
assert.equal(legacy.role, "self");
assert.equal((legacy.profile as { birth_date: string }).birth_date, "1990-01-02");
selfLoads = 0;
const other = await resolveConsultationSubject({
userId: "user-self",
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Stale Label" },
clientBirth,
loadSelfProfile: async () => {
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async (chartProfileId) => {
assert.equal(chartProfileId, "chart-other");
return ownedOther();
},
});
assert.equal(selfLoads, 0);
assert.equal(other.role, "other");
assert.equal(other.name, "Synthetic Other");
assert.equal(other.chartProfileId, "chart-other");
const row = other.profile as { birth_date: string; latitude: number; reported_birth_time: string };
assert.equal(row.birth_date, "1988-03-04");
assert.equal(row.latitude, 22.3);
assert.equal(row.reported_birth_time, "09:20");
assert.equal("year" in row, false);
assert.equal("lat" in row, false);
});
test("another user's id, a random id, a role mismatch, and a deleted or incomplete profile fail closed", async () => {
const cases: Array<{ name: string; owned: OwnedChartProfile | null | Error; code: string }> = [
{ name: "other user", owned: ownedOther(otherProfile, "user-other"), code: "subject_not_found" },
{ name: "random id", owned: null, code: "subject_not_found" },
{ name: "deleted", owned: null, code: "subject_not_found" },
{
name: "role mismatch",
owned: { id: "chart-other", userId: "user-self", role: "self", profile: otherProfile },
code: "subject_role_mismatch",
},
{
name: "missing",
owned: null,
code: "subject_not_found",
},
{
name: "incomplete",
owned: ownedOther({ name: "Synthetic Other", year: 2001, lat: 1, city: "Client City" }),
code: "subject_incomplete",
},
];
for (const item of cases) {
let selfLoads = 0;
const error = await resolveConsultationSubject({
userId: "user-self",
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
clientBirth,
loadSelfProfile: async () => {
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async () => {
if (item.owned instanceof Error) throw item.owned;
return item.owned;
},
}).then(() => null, (caught: unknown) => caught);
assert.ok(error instanceof ConsultationSubjectError, item.name);
assert.equal(error.code, item.code, item.name);
assert.equal(error.message.includes("Client Name"), false, item.name);
assert.equal(error.message.includes("Synthetic"), false, item.name);
assert.equal(selfLoads, 0, item.name);
}
});
test("a forged display name cannot complete or replace the server profile", async () => {
let selfLoads = 0;
const missingName = await resolveConsultationSubject({
userId: "user-self",
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
clientBirth,
loadSelfProfile: async () => {
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async () => ownedOther({ ...otherProfile, name: "" }),
}).then(() => null, (caught: unknown) => caught);
assert.ok(missingName instanceof ConsultationSubjectError);
assert.equal(missingName.code, "subject_incomplete");
assert.equal(missingName.message.includes("Client Name"), false);
assert.equal(selfLoads, 0);
const claimedSelf = await resolveConsultationSubject({
userId: "user-self",
binding: { chartProfileId: "chart-other", chartProfileRole: "self", chartProfileName: "Synthetic Self" },
clientBirth,
loadSelfProfile: async () => {
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async () => ownedOther(),
}).then(() => null, (caught: unknown) => caught);
assert.ok(claimedSelf instanceof ConsultationSubjectError);
assert.equal(claimedSelf.code, "subject_role_mismatch");
assert.equal(selfLoads, 0);
});
test("database failures stay closed and do not leak internals", async () => {
const error = await resolveConsultationSubject({
userId: "user-self",
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
loadSelfProfile: async () => selfProfile,
loadOwnedChartProfile: async () => {
throw new Error("duplicate key value violates unique constraint for user-other@example");
},
}).then(() => null, (caught: unknown) => caught);
assert.ok(error instanceof ConsultationSubjectError);
assert.equal(error.code, "subject_unavailable");
assert.equal(error.message.includes("duplicate"), false);
assert.equal(error.message.includes("example"), false);
const response = consultationSubjectFailureResponse(error);
const encoded = JSON.stringify(response);
assert.equal(encoded.includes("duplicate"), false);
assert.equal(encoded.includes("user-other"), false);
assert.equal(encoded.includes("@"), false);
assert.match(response.body.message, /不会扣点/);
});
test("conflicting client birth fields still lose to the server profile", async () => {
let reserves = 0;
let selfLoads = 0;
const prepared = await prepareConsultationRoute({
userId: "user-self",
mode: "unverified_birth_time",
subject: {
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" },
clientBirth,
loadOwnedChartProfile: async () => ownedOther(),
},
loadProfile: async () => {
selfLoads += 1;
return selfProfile;
},
beforeReserve({ serverChart, consultationMode }) {
assert.equal(consultationMode, "unverified_birth_time");
assert.equal(serverChart?.name, "Synthetic Other");
assert.equal(serverChart?.toolInput.year, 1988);
assert.equal(serverChart?.toolInput.month, 3);
assert.equal(serverChart?.toolInput.day, 4);
assert.equal(serverChart?.toolInput.hour, 9);
assert.equal(serverChart?.toolInput.minute, 20);
assert.equal(serverChart?.toolInput.city, "Synthetic Harbor");
assert.equal(serverChart?.toolInput.lat, 22.3);
assert.equal(serverChart?.toolInput.lon, 114.2);
assert.equal(serverChart?.toolInput.tz, 8);
assert.equal(serverChart?.toolInput.ayanamsa, "lahiri");
},
reserve: async () => {
reserves += 1;
return { charged: true };
},
});
assert.equal(selfLoads, 0);
assert.equal(reserves, 1);
assert.equal(prepared.subject?.role, "other");
assert.equal(prepared.subject?.name, "Synthetic Other");
assert.notEqual(prepared.serverChart?.toolInput.year, clientBirth.year);
});
test("consult preparation receives the other profile and a resolver failure does not charge or prepare a model call", async () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(route, /chart_profile_id,chart_profile_name,chart_profile_role/);
assert.match(route, /clientBirth: parsed\.data/);
assert.match(route, /loadOwnedChartProfile/);
assert.match(route, /consultationSubjectBindingFromSession\(chatSession\)/);
assert.doesNotMatch(route, /parsed\.data\.name/);
const failureHandler = route.slice(
route.indexOf("instanceof ConsultationSubjectError"),
route.indexOf("const modelSelection"),
);
assert.match(failureHandler, /consultationSubjectFailureResponse/);
assert.doesNotMatch(failureHandler, /getJyotishAgent|streamAgentResponse|streamTextResponse/);
let reserves = 0;
let selfLoads = 0;
let preparations = 0;
const error = await prepareConsultationRoute({
userId: "user-self",
mode: "unverified_birth_time",
subject: {
binding: consultationSubjectBindingFromSession({
chart_profile_id: "chart-missing",
chart_profile_role: "other",
chart_profile_name: "Synthetic Other",
}),
clientBirth,
loadOwnedChartProfile: async () => null,
},
loadProfile: async () => {
selfLoads += 1;
return selfProfile;
},
beforeReserve() {
preparations += 1;
},
reserve: async () => {
reserves += 1;
return { charged: true };
},
}).then(() => null, (caught: unknown) => caught);
assert.ok(error instanceof ConsultationSubjectError);
assert.equal(error.code, "subject_not_found");
assert.equal(selfLoads, 0);
assert.equal(preparations, 0);
assert.equal(reserves, 0);
assert.equal(error instanceof ConsultationProfileTruthError, false);
});
test("an other chart does not inherit the account candidate range", async () => {
let rangeLoads = 0;
const prepared = await prepareConsultationRoute({
userId: "user-self",
mode: "verified_chart",
subject: {
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
loadOwnedChartProfile: async () => ownedOther({
...otherProfile,
time: "09:20",
birthTimeStatus: "confirmed",
}),
},
loadProfile: async () => selfProfile,
loadCandidateRange: async () => {
rangeLoads += 1;
return { startTime: "08:00", endTime: "08:30" };
},
reserve: async () => "reserved",
});
assert.equal(rangeLoads, 0);
assert.equal(prepared.serverChart?.toolInput.hour, 9);
assert.equal("candidate_range" in (prepared.serverChart?.toolInput ?? {}), false);
});
test("a session with messages rejects rebinding and an empty session stores the server name", async () => {
const stored = consultationSubjectBindingFromSession({
chart_profile_id: "chart-other",
chart_profile_role: "other",
chart_profile_name: "Synthetic Other",
});
let selfLoads = 0;
const locked = await authoritativeSessionBinding({
userId: "user-self",
requested: {
chart_profile_id: "chart-next",
chart_profile_role: "other",
chart_profile_name: "Client Name",
},
existingMessages: [{ role: "user", text: "synthetic question" }],
existingBinding: stored,
loadSelfProfile: async () => {
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async () => ownedOther(),
}).then(() => null, (caught: unknown) => caught);
assert.ok(locked instanceof ConsultationSubjectError);
assert.equal(locked.code, "subject_locked");
assert.equal(selfLoads, 0);
const lockedResponse = consultationSubjectFailureResponse(locked);
assert.equal(lockedResponse.status, 409);
assert.equal(JSON.stringify(lockedResponse).includes("Client Name"), false);
const forgedRole = await authoritativeSessionBinding({
userId: "user-self",
requested: {
chart_profile_id: "chart-other",
chart_profile_role: "self",
chart_profile_name: "Synthetic Self",
},
existingMessages: [],
existingBinding: null,
loadSelfProfile: async () => {
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async () => ownedOther(),
}).then(() => null, (caught: unknown) => caught);
assert.ok(forgedRole instanceof ConsultationSubjectError);
assert.equal(forgedRole.code, "subject_role_mismatch");
assert.equal(selfLoads, 0);
const created = await authoritativeSessionBinding({
userId: "user-self",
requested: {
chart_profile_id: "chart-other",
chart_profile_role: "other",
chart_profile_name: "Client Name",
},
existingMessages: [],
existingBinding: null,
loadSelfProfile: async () => {
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async () => ownedOther(),
});
assert.equal(selfLoads, 0);
assert.deepEqual(created, {
chart_profile_id: "chart-other",
chart_profile_name: "Synthetic Other",
chart_profile_role: "other",
});
});
test("refresh and deep link keep the stored binding instead of a global active chart", async () => {
const activeChartId = "self";
const stored = consultationSubjectBindingFromSession({
chart_profile_id: "chart-other",
chart_profile_name: "Synthetic Other",
chart_profile_role: "other",
});
assert.notEqual(activeChartId, stored.chartProfileId);
let selfLoads = 0;
const resolved = await resolveConsultationSubject({
userId: "user-self",
binding: stored,
loadSelfProfile: async () => {
selfLoads += 1;
return selfProfile;
},
loadOwnedChartProfile: async () => ownedOther(),
});
assert.equal(selfLoads, 0);
assert.equal((resolved.profile as { birth_date: string }).birth_date, "1988-03-04");
const refreshed = await authoritativeSessionBinding({
userId: "user-self",
requested: {
title: "synthetic title",
chart_profile_id: stored.chartProfileId,
chart_profile_name: stored.chartProfileName,
chart_profile_role: stored.chartProfileRole,
},
existingMessages: [{ role: "assistant", text: "synthetic reply" }],
existingBinding: stored,
loadSelfProfile: async () => {
throw new Error("refresh must not read profiles");
},
loadOwnedChartProfile: async () => {
throw new Error("refresh must not reread chart_profiles");
},
});
assert.equal(refreshed?.chart_profile_id, "chart-other");
assert.equal(refreshed?.chart_profile_role, "other");
assert.equal(refreshed?.chart_profile_name, "Synthetic Other");
const sessions = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const sessionItem = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
assert.match(sessions, /authoritativeSessionBinding/);
assert.match(sessionItem, /authoritativeSessionBinding/);
assert.match(sessionItem, /sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role/);
assert.doesNotMatch(sessionItem, /activeChartId/);
assert.doesNotMatch(sessions, /activeChartId/);
});
test("a concurrent delete observed by the send fails closed and does not fall back to self", async () => {
let visible: OwnedChartProfile | null = ownedOther();
let selfLoads = 0;
let reserves = 0;
let preparations = 0;
const pending = prepareConsultationRoute({
userId: "user-self",
mode: "unverified_birth_time",
subject: {
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Synthetic Other" },
clientBirth,
loadOwnedChartProfile: async () => {
await Promise.resolve();
return visible;
},
},
loadProfile: async () => {
selfLoads += 1;
return selfProfile;
},
beforeReserve() {
preparations += 1;
},
reserve: async () => {
reserves += 1;
return { charged: true };
},
});
visible = null;
const error = await pending.then(() => null, (caught: unknown) => caught);
assert.ok(error instanceof ConsultationSubjectError);
assert.equal(error.code, "subject_not_found");
assert.equal(selfLoads, 0);
assert.equal(preparations, 0);
assert.equal(reserves, 0);
const source: { date: string; name: string } = { ...otherProfile };
const resolved = await resolveConsultationSubject({
userId: "user-self",
binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null },
loadSelfProfile: async () => selfProfile,
loadOwnedChartProfile: async () => ownedOther(source),
});
source.date = "2000-01-01";
source.name = "Changed After Read";
assert.equal((resolved.profile as { birth_date?: string }).birth_date, "1988-03-04");
assert.equal(resolved.name, "Synthetic Other");
});