- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and an example answer again and is server-owned on the zero-evidence opening; the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no question). Stem de-dup compares whole sentences / near-equality instead of a 12-char prefix, which had deleted the body's examples sentence sinceaa7ccb30(BUG-604) +dd8f35f7(BUG-648). - BUG-1050: plain step labels; a finished step label shows once and 「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the in-progress wording. - Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated. - VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist and screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
279 lines
10 KiB
TypeScript
279 lines
10 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,
|
||
// 原值: "10.0.25";新值: "10.0.26";原因: BUG-668 新案绑定现行 Skill
|
||
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
|
||
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
|
||
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
|
||
skill_version: "10.0.31",
|
||
};
|
||
}
|
||
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);
|
||
// 原值: "10.0.25"
|
||
// 原值: "10.0.26"
|
||
// 新值: "10.0.27"
|
||
// 原因: 出卡精度门槛 + 引导式补经历
|
||
// 原因: BUG-668 定向补事第四选项写进 Skill
|
||
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
|
||
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
|
||
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
|
||
assert.equal(response.skillVersion, "10.0.31");
|
||
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");
|
||
// 原值: "10.0.25"
|
||
// 原值: "10.0.26"
|
||
// 新值: "10.0.27"
|
||
// 原因: 出卡精度门槛 + 引导式补经历
|
||
// 原因: BUG-668 定向补事第四选项写进 Skill
|
||
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
|
||
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
|
||
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
|
||
assert.equal(openCall.args.p_skill_version, "10.0.31");
|
||
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);
|
||
});
|