f708edf365
SQL kinds now match the TypeScript ledger so batch ingest can confirm dated events. Confirm no longer burns the opening focus, recap uses server date labels, and the Agent sees indistinguishable width instead of a fake unique minute. Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
8.9 KiB
TypeScript
259 lines
8.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
entrySummaryFromResponse,
|
|
isResumableRectificationStatus,
|
|
isTerminalRectificationStatus,
|
|
openRectificationRequestBody,
|
|
openResponseFromPayload,
|
|
rectificationEntryLabels,
|
|
resolveRectificationEntryAction,
|
|
} from "../src/lib/rectification-entry.ts";
|
|
import {
|
|
openRectificationCaseRequestSchema,
|
|
shouldStartOpening,
|
|
} from "../src/lib/rectification-agentic/v9/open-request.ts";
|
|
import {
|
|
mapRectificationRpcError,
|
|
openRectificationCase,
|
|
RectificationCaseServiceError,
|
|
} from "../src/lib/rectification-agentic/v9/case-service.ts";
|
|
import {
|
|
CASE_ID,
|
|
SESSION_ID,
|
|
fakeAccounting,
|
|
type FakeRpcHandler,
|
|
} from "./rectification-v9-test-support.ts";
|
|
|
|
const uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
|
|
|
test("homepage entry requests a new case and shows 开始新的生时校正", () => {
|
|
const body = openRectificationRequestBody("homepage", null);
|
|
assert.equal(body.intent, "homepage");
|
|
assert.ok(typeof body.requestId === "string" && body.requestId.length > 0);
|
|
assert.equal("sessionId" in body, false);
|
|
|
|
const summary = entrySummaryFromResponse({
|
|
has_resumable_case: false,
|
|
has_terminal_case_with_time: false,
|
|
latest_resumable: null,
|
|
latest_terminal: null,
|
|
});
|
|
assert.equal(resolveRectificationEntryAction(summary), "start");
|
|
assert.equal(rectificationEntryLabels.start, "开始新的生时校正");
|
|
});
|
|
|
|
test("homepage entry with a resumable case still offers a separate new correction", () => {
|
|
const summary = entrySummaryFromResponse({
|
|
has_resumable_case: true,
|
|
has_terminal_case_with_time: false,
|
|
latest_resumable: {
|
|
case_id: CASE_ID,
|
|
status: "collecting_evidence",
|
|
last_activity_at: "2026-08-12T10:00:00.000Z",
|
|
},
|
|
latest_terminal: null,
|
|
});
|
|
assert.equal(resolveRectificationEntryAction(summary), "start");
|
|
assert.equal(rectificationEntryLabels.start, "开始新的生时校正");
|
|
|
|
const opened = openResponseFromPayload({
|
|
disposition: "created",
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
status: "draft",
|
|
shouldStartOpening: true,
|
|
skillVersion: "10.0.0",
|
|
});
|
|
assert.equal(opened?.disposition, "created");
|
|
assert.equal(opened?.shouldStartOpening, true);
|
|
assert.equal(opened?.caseId, CASE_ID);
|
|
assert.equal(opened?.sessionId, SESSION_ID);
|
|
});
|
|
|
|
test("homepage entry with only a terminal case shows 再次校正 and creates a new case", () => {
|
|
const summary = entrySummaryFromResponse({
|
|
has_resumable_case: false,
|
|
has_terminal_case_with_time: true,
|
|
latest_resumable: null,
|
|
latest_terminal: {
|
|
case_id: CASE_ID,
|
|
status: "confirmed",
|
|
has_usable_time: true,
|
|
},
|
|
});
|
|
assert.equal(resolveRectificationEntryAction(summary), "restart");
|
|
assert.equal(rectificationEntryLabels.restart, "再次校正");
|
|
});
|
|
|
|
test("legacy completed sessions never derive 继续上次 from the session list", () => {
|
|
// The CTA must be driven by the server entry summary, not by scanning
|
|
// sessionType in the client session list.
|
|
const summary = entrySummaryFromResponse({
|
|
has_resumable_case: false,
|
|
has_terminal_case_with_time: false,
|
|
latest_resumable: null,
|
|
latest_terminal: null,
|
|
});
|
|
assert.equal(resolveRectificationEntryAction(summary), "start");
|
|
assert.notEqual(rectificationEntryLabels.start, "继续上次校正");
|
|
});
|
|
|
|
test("sidebar click passes the exact sessionId and opens exactly that session", () => {
|
|
const body = openRectificationRequestBody("session", SESSION_ID);
|
|
assert.equal(body.intent, "session");
|
|
assert.equal(body.sessionId, SESSION_ID);
|
|
});
|
|
|
|
test("sidebar click on a terminal session opens read-only history", () => {
|
|
const opened = openResponseFromPayload({
|
|
disposition: "readonly",
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
status: "confirmed",
|
|
shouldStartOpening: false,
|
|
skillVersion: "9.0.0",
|
|
});
|
|
assert.equal(opened?.disposition, "readonly");
|
|
assert.equal(isTerminalRectificationStatus(opened?.status ?? ""), true);
|
|
assert.equal(isResumableRectificationStatus(opened?.status ?? ""), false);
|
|
assert.equal(opened?.shouldStartOpening, false);
|
|
});
|
|
|
|
test("double-click sends an idempotency key and never business state", () => {
|
|
const first = openRectificationRequestBody("homepage", null);
|
|
const second = openRectificationRequestBody("homepage", null);
|
|
// Both requests carry a requestId; the server serializes on the user and
|
|
// dedupes via the open ledger. No caseId/birth data/range is ever sent.
|
|
assert.ok(first.requestId);
|
|
assert.ok(second.requestId);
|
|
for (const body of [first, second]) {
|
|
assert.equal("caseId" in body, false);
|
|
assert.equal("birth_date" in body, false);
|
|
assert.equal("candidate_range" in body, false);
|
|
assert.equal("userId" in body, false);
|
|
}
|
|
});
|
|
|
|
test("two tabs produce distinct request ids; the open schema stays minimal", () => {
|
|
const tabA = openRectificationRequestBody("homepage", null);
|
|
const tabB = openRectificationRequestBody("homepage", null);
|
|
assert.notEqual(tabA.requestId, tabB.requestId);
|
|
for (const parsed of [tabA, tabB]) {
|
|
const result = openRectificationCaseRequestSchema.safeParse(parsed);
|
|
assert.equal(result.success, true);
|
|
}
|
|
});
|
|
|
|
test("non-owner sessionId maps to a 404 business error, never a leak", () => {
|
|
const view = mapRectificationRpcError(
|
|
new Error("agentic_rectification_session_not_found"),
|
|
);
|
|
assert.equal(view.status, 404);
|
|
assert.equal(view.code, "case_session_not_found");
|
|
});
|
|
|
|
test("profile incomplete prevents case creation with an explicit business error", async () => {
|
|
const accounting = fakeAccounting({});
|
|
// loadV9RectificationProfile throws profile_incomplete before the open RPC
|
|
// runs when the profile row is missing required fields.
|
|
const profileClient = {
|
|
...accounting.client,
|
|
from: () => ({
|
|
select: () => ({
|
|
eq: () => ({
|
|
single: async () => ({ data: { birth_date: null, latitude: null }, error: null }),
|
|
}),
|
|
}),
|
|
}),
|
|
} as never;
|
|
await assert.rejects(
|
|
openRectificationCase(profileClient, "user-1", {
|
|
intent: "homepage",
|
|
requestId: uuid,
|
|
}),
|
|
(error: unknown) => error instanceof RectificationCaseServiceError && error.code === "profile_incomplete",
|
|
);
|
|
});
|
|
|
|
test("shouldStartOpening is server-owned: only freshly created never-started cases", () => {
|
|
assert.equal(shouldStartOpening("created", 0), true);
|
|
assert.equal(shouldStartOpening("created", 1), false);
|
|
assert.equal(shouldStartOpening("resumed", 0), false);
|
|
assert.equal(shouldStartOpening("readonly", 3), false);
|
|
});
|
|
|
|
test("empty client message cache never repeats opening when the case has turns", () => {
|
|
// The client messages array may be empty after refresh, but exact-session
|
|
// recovery stays resumed: shouldStartOpening is false, so the opening turn
|
|
// is not re-emitted.
|
|
const opened = openResponseFromPayload({
|
|
disposition: "resumed",
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
status: "collecting_evidence",
|
|
shouldStartOpening: false,
|
|
skillVersion: "9.0.0",
|
|
});
|
|
assert.equal(opened?.disposition, "resumed");
|
|
assert.equal(opened?.shouldStartOpening, false);
|
|
});
|
|
|
|
test("open RPC passes the pinned skill and server-derived baseline only", async () => {
|
|
const rpc: FakeRpcHandler = (fn) => {
|
|
if (fn === "open_agentic_rectification_case_v2") {
|
|
return {
|
|
disposition: "created",
|
|
case_id: CASE_ID,
|
|
session_id: SESSION_ID,
|
|
status: "draft",
|
|
should_start_opening: true,
|
|
skill_version: "10.0.1",
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
const profile = {
|
|
birth_date: "1997-08-08",
|
|
birth_place_label: "河北省邯郸市",
|
|
reported_birth_time: "05:00",
|
|
active_birth_time: null,
|
|
birth_time_source: "family_exact",
|
|
birth_time_period: null,
|
|
uncertainty_before_minutes: 10,
|
|
uncertainty_after_minutes: 10,
|
|
latitude: 36.420487,
|
|
longitude: 114.209936,
|
|
timezone_id: "Asia/Shanghai",
|
|
timezone_offset: 8,
|
|
};
|
|
const accounting = fakeAccounting({
|
|
open_agentic_rectification_case_v2: rpc,
|
|
});
|
|
const profileClient = {
|
|
...accounting.client,
|
|
from: () => ({
|
|
select: () => ({
|
|
eq: () => ({
|
|
single: async () => ({ data: profile, error: null }),
|
|
}),
|
|
}),
|
|
}),
|
|
} as never;
|
|
const response = await openRectificationCase(profileClient, "user-1", {
|
|
intent: "homepage",
|
|
requestId: uuid,
|
|
});
|
|
assert.equal(response.disposition, "created");
|
|
assert.equal(response.shouldStartOpening, true);
|
|
assert.equal(response.skillVersion, "10.0.1");
|
|
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
|
|
assert.ok(openCall);
|
|
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
|
|
assert.equal(openCall.args.p_skill_version, "10.0.1");
|
|
assert.equal(openCall.args.p_user_id, "user-1");
|
|
// The server derives the baseline; the request never carries it from the browser.
|
|
assert.equal("birth_date" in openCall.args, false);
|
|
});
|