- D2: each intent-classifier attempt is capped at 10 s (same session model, thinking untouched); a hang takes the existing retry -> classifier_unavailable path. Success is timed too (RectificationClassifierDiagnostic). - D3: a typed message builds the NDJSON stream first; the first line is turn.progress "received", then the classifier and deterministic replies run inside the stream. Preflight rejections become turn.rejected (old status, code, message) and the client handles them like the old HTTP rejection. Stage lines 收到,正在对照你的档案… / 正在记下这件事… / 正在重新对照盘面… / 正在准备下一个问题… are driven by existing tool events and engine calls, are transient (live row only) and never persisted. VOICE / DESIGN updated. - D4: RectificationRunDiagnostic records per-step start/end, provider token usage incl. reasoning tokens, classifier timing and per-engine-call durations (AsyncLocalStorage scope per turn); RectificationTurnDiagnostic for deterministic turns. No user text, birth data or model text. - D5: /v5/versions memo (30 s, complete identities only, per transport); the exit gate skips its second persistNextInterviewIfIdle when the run's own call found the next focus already active (provably identical). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
|
||
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "../src/lib/home-bootstrap.ts";
|
||
import {
|
||
declaredBirthTime,
|
||
hydrateRectificationCase,
|
||
parsePersistedRectificationTurns,
|
||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
rectificationAdoptingLabel,
|
||
rectificationBoardEmptyCopy,
|
||
rectificationConversationState,
|
||
rectificationInitialLiveLabel,
|
||
rectificationQuestionGapState,
|
||
rectificationQuestionRetryActive,
|
||
interviewCollectWaiting,
|
||
interviewChoiceCardUnavailable,
|
||
persistedQuestionSurface,
|
||
interviewStopReasonFromSnapshot,
|
||
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
|
||
rectificationReadonlyRangeCopy,
|
||
type RectificationQuestionGapInput,
|
||
} from "../src/lib/rectification-surface-state.ts";
|
||
|
||
const gapBase: RectificationQuestionGapInput = {
|
||
liveQuestionVisible: false,
|
||
questionMissing: true,
|
||
questionLoadFailed: false,
|
||
busy: false,
|
||
readonly: false,
|
||
regenerating: false,
|
||
snapshotLoaded: true,
|
||
resumableCase: true,
|
||
retryAttempts: 0,
|
||
};
|
||
|
||
test("conversation state: readonly and busy win, then opening, then empty", () => {
|
||
const base = { messageCount: 0, busy: false, readonly: false, shouldStartOpening: false, openingStarted: false };
|
||
assert.equal(rectificationConversationState({ ...base, readonly: true }), "readonly");
|
||
assert.equal(rectificationConversationState({ ...base, busy: true }), "busy");
|
||
assert.equal(rectificationConversationState({ ...base, messageCount: 3 }), "conversation");
|
||
assert.equal(rectificationConversationState({ ...base, shouldStartOpening: true }), "opening");
|
||
assert.equal(rectificationConversationState({ ...base, openingStarted: true }), "opening");
|
||
// A resumed Case with no turns and no server-started opening: the reader needs a way to begin (BUG-507).
|
||
assert.equal(rectificationConversationState(base), "empty");
|
||
});
|
||
|
||
test("question gap: a live question inside a message means no gap", () => {
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true, questionMissing: false }), "idle");
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true }), "idle");
|
||
});
|
||
|
||
test("question gap: preparing while retries remain, then unavailable with a reload", () => {
|
||
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
|
||
// The server names a question that no settled message carries live yet.
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false }), "preparing");
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: 5 }), "unavailable");
|
||
// The server itself said the question could not be loaded: no point retrying on a timer.
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionLoadFailed: true }), "unavailable");
|
||
assert.equal(rectificationQuestionRetryActive("preparing"), true);
|
||
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
|
||
assert.equal(rectificationQuestionRetryActive("idle"), false);
|
||
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
|
||
});
|
||
|
||
test("question gap: collect waiting is not the unavailable repair path", () => {
|
||
assert.equal(
|
||
interviewCollectWaiting({
|
||
stopReason: "insufficient_dated_events",
|
||
sessionOutcome: "collect_evidence",
|
||
questionMissing: true,
|
||
}),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
interviewCollectWaiting({
|
||
sessionOutcome: "collect_evidence",
|
||
questionMissing: true,
|
||
}),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
rectificationQuestionGapState({
|
||
...gapBase,
|
||
collectWaiting: true,
|
||
questionLoadFailed: true,
|
||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
}),
|
||
"collect_waiting",
|
||
);
|
||
assert.equal(rectificationQuestionRetryActive("collect_waiting"), false);
|
||
assert.equal(RECTIFICATION_COLLECT_WAITING_PLACEHOLDER, "再说一件带年月的事");
|
||
assert.equal(
|
||
interviewStopReasonFromSnapshot({
|
||
interview: { stop_reason: "insufficient_domains" },
|
||
}),
|
||
"insufficient_domains",
|
||
);
|
||
});
|
||
|
||
test("question gap: a snapshot that never arrived is a gap the same retries fill", () => {
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false }), "preparing");
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
|
||
});
|
||
|
||
test("question gap: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => {
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, busy: true }), "idle");
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, readonly: true }), "idle");
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, regenerating: true }), "idle");
|
||
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle");
|
||
});
|
||
|
||
test("a persisted choice with a live card is not the spoken-only prompt surface", () => {
|
||
assert.equal(
|
||
persistedQuestionSurface({
|
||
questionKind: "choice",
|
||
hasChoiceCard: true,
|
||
questionPersisted: true,
|
||
}),
|
||
"choice_card",
|
||
);
|
||
});
|
||
|
||
test("question gap: a persisted focus question is shown instead of preparing", () => {
|
||
assert.equal(
|
||
rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionPersisted: true }),
|
||
"persisted_question",
|
||
);
|
||
assert.equal(
|
||
rectificationQuestionGapState({
|
||
...gapBase,
|
||
liveQuestionVisible: true,
|
||
questionMissing: false,
|
||
questionPersisted: true,
|
||
}),
|
||
"idle",
|
||
);
|
||
assert.equal(rectificationQuestionRetryActive("persisted_question"), false);
|
||
});
|
||
|
||
test("question gap: a delivered range is not the unavailable repair path", () => {
|
||
assert.equal(
|
||
rectificationQuestionGapState({
|
||
...gapBase,
|
||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
sessionOutcome: "completed_with_range",
|
||
}),
|
||
"delivered",
|
||
);
|
||
assert.equal(
|
||
rectificationQuestionGapState({
|
||
...gapBase,
|
||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
stopReason: "tied_first",
|
||
}),
|
||
"delivered",
|
||
);
|
||
assert.equal(rectificationQuestionRetryActive("delivered"), false);
|
||
});
|
||
|
||
test("question gap: a choice question without a card is unavailable, not collect waiting", () => {
|
||
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true);
|
||
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false);
|
||
assert.equal(interviewChoiceCardUnavailable({ questionKind: "collect_spoken", hasChoiceCard: false }), false);
|
||
// 原值: collect_spoken 一律不是 deadChoice
|
||
// 新值: collect:targeted:<domain> 存在题的口述形态也是 deadChoice;year 与普通口述仍不是
|
||
// 原因: BUG-673 定向存在题被存成 collect_spoken 时必须走修复入口
|
||
assert.equal(
|
||
interviewChoiceCardUnavailable({
|
||
questionKind: "collect_spoken",
|
||
hasChoiceCard: false,
|
||
questionId: "collect:targeted:family",
|
||
}),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
interviewChoiceCardUnavailable({
|
||
questionKind: "collect_spoken",
|
||
hasChoiceCard: false,
|
||
questionId: "collect:other:collect_method_evidence",
|
||
}),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test("readonly range copy never says 收窄 or 才会变", () => {
|
||
assert.equal(
|
||
rectificationReadonlyRangeCopy({
|
||
range: ["04:45", "05:15"],
|
||
sessionOutcome: "adopt_representative",
|
||
discriminatingEventProbes: [],
|
||
contrastProbes: [],
|
||
}),
|
||
"目前范围 04:45–05:15,选择题已问完,下面是当前范围",
|
||
);
|
||
// 原值: discriminate_candidates 且无带年月探针 →「选择题已问完,再补带年月的经历才会变」
|
||
// 新值: 有交付卡才写「选择题已问完,下面是当前范围」;仍在区分时写「还在核对」
|
||
// 原因: BUG-652 没有结果卡时不得写「做不了」口径
|
||
const stillOpen = rectificationReadonlyRangeCopy({
|
||
range: ["04:45", "05:15"],
|
||
sessionOutcome: "discriminate_candidates",
|
||
discriminatingEventProbes: [{ year: 2016 }],
|
||
});
|
||
assert.match(stillOpen, /还在核对/);
|
||
assert.doesNotMatch(stillOpen, /收窄|才会变/);
|
||
const collecting = rectificationReadonlyRangeCopy({
|
||
range: ["04:45", "05:15"],
|
||
sessionOutcome: "collect_evidence",
|
||
});
|
||
assert.match(collecting, /再说一件带年月的事就能继续/);
|
||
assert.doesNotMatch(collecting, /选择题已问完|收窄|才会变/);
|
||
});
|
||
|
||
test("live-row labels follow the action that started the turn", () => {
|
||
assert.equal(rectificationInitialLiveLabel("opening"), "正在读取你的出生资料,准备第一个问题…");
|
||
// 原值: "正在处理…"
|
||
// 新值: "收到,正在对照你的档案…"
|
||
// 原因: BUG-1047 D3,打字回答发出即显示第一句阶段进度,替代一直不变的「正在处理…」
|
||
assert.equal(rectificationInitialLiveLabel("message"), "收到,正在对照你的档案…");
|
||
assert.equal(rectificationInitialLiveLabel("read_only", "正在记录本次选择…"), "正在记录本次选择…");
|
||
assert.equal(rectificationInitialLiveLabel("read_only"), "正在处理…");
|
||
assert.equal(rectificationAdoptingLabel("04:53"), "正在采用 04:53…");
|
||
});
|
||
|
||
test("board copy before any candidate shows the declared minute, never invented data", () => {
|
||
// 原值: 「填报出生时间 05:10」
|
||
// 新值: 缺来源时按大概时间;医院记录用「出生记录时间」
|
||
// 原因: BUG-690 推算值不得称作「你的出生时间」
|
||
assert.deepEqual(rectificationBoardEmptyCopy("05:10"), {
|
||
clock: "05:10",
|
||
lines: ["你填的大概时间 05:10", "回答几个问题后,这里会显示宫位随时间的变化。"],
|
||
});
|
||
assert.deepEqual(rectificationBoardEmptyCopy("05:10", "hospital_record"), {
|
||
clock: "05:10",
|
||
lines: ["出生记录时间 05:10", "回答几个问题后,这里会显示宫位随时间的变化。"],
|
||
});
|
||
assert.deepEqual(rectificationBoardEmptyCopy(null), {
|
||
clock: null,
|
||
lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"],
|
||
});
|
||
assert.equal(declaredBirthTime({ reportedTime: "5:10", time: "" }), "5:10");
|
||
assert.equal(declaredBirthTime({ reportedTime: "", time: "14:30" }), "14:30");
|
||
assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null);
|
||
assert.equal(declaredBirthTime({ reportedTime: "凌晨", time: "" }), null);
|
||
});
|
||
|
||
test("persisted turns keep their question, offer and tool activities through the parser", () => {
|
||
const turns = parsePersistedRectificationTurns([
|
||
{
|
||
id: "t1",
|
||
role: "assistant",
|
||
text: "先看事业。",
|
||
status: "completed",
|
||
question: { focus_id: "f1", kind: "choice" },
|
||
offer_result_id: "r1",
|
||
receipt: {
|
||
status: "ready",
|
||
phases: ["compute"],
|
||
tools: ["rectification-compare-candidates"],
|
||
methods: ["d9-navamsa", "d10-dashamsa"],
|
||
tool_activities: [{ tool: "rectification-compare-candidates", status: "completed", methods: ["d9-navamsa"], started_at: null, elapsed_ms: 12 }],
|
||
answer_origin: "host_fallback",
|
||
},
|
||
},
|
||
{ id: "t2", role: "user", text: "2014 年毕业。" },
|
||
"junk",
|
||
]);
|
||
assert.equal(turns.length, 3);
|
||
assert.equal(turns[0]?.offer_result_id, "r1");
|
||
assert.deepEqual(turns[0]?.question, { focus_id: "f1", kind: "choice" });
|
||
assert.deepEqual(turns[0]?.receipt?.methods, ["d9-navamsa", "d10-dashamsa"]);
|
||
assert.equal(turns[0]?.receipt?.tool_activities?.[0]?.tool, "rectification-compare-candidates");
|
||
assert.equal(turns[0]?.receipt?.answer_origin, "host_fallback");
|
||
assert.equal(turns[1]?.role, "user");
|
||
assert.equal(turns[1]?.status, "completed");
|
||
assert.equal(turns[1]?.receipt, null);
|
||
assert.equal(turns[2]?.id, "");
|
||
assert.deepEqual(parsePersistedRectificationTurns(null), []);
|
||
});
|
||
|
||
test("hydration reads turns and snapshot from one Case read and is one constant with the home reveal budget", async () => {
|
||
assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, BOOTSTRAP_PREPARE_TIMEOUT_MS);
|
||
const calls: string[] = [];
|
||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||
calls.push(String(input));
|
||
return new Response(JSON.stringify({
|
||
turns: [{ id: "t1", role: "assistant", text: "hi", status: "completed" }],
|
||
current_question: { kind: "choice", prompt: "哪一年?", focus_id: "f1", question_id: "q1" },
|
||
case: { status: "collecting" },
|
||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||
}) as typeof fetch;
|
||
const hydration = await hydrateRectificationCase("case-1", "session-1", { fetchImpl, timeoutMs: 1_000 });
|
||
assert.equal(calls.length, 1);
|
||
assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/);
|
||
assert.equal(hydration.complete, true);
|
||
assert.equal(hydration.turns.length, 1);
|
||
assert.equal((hydration.snapshot?.case as { status?: unknown } | undefined)?.status, "collecting");
|
||
});
|
||
|
||
test("hydration resolves incomplete on failure and when the deadline passes first", async () => {
|
||
const failing = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
||
const failed = await hydrateRectificationCase("case-1", "session-1", { fetchImpl: failing, timeoutMs: 1_000 });
|
||
// 原值: { turns: [], snapshot: null, complete: false }
|
||
// 新值: 同上并带 failure.status(HTTP 状态或 timeout / network)
|
||
// 原因: BUG-969 快照失败不得静默,调用方要能区分超时和 503
|
||
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false, failure: { status: 503 } });
|
||
|
||
let fire: (() => void) | undefined;
|
||
const never = (() => new Promise<Response>(() => {})) as typeof fetch;
|
||
const late = hydrateRectificationCase("case-1", "session-1", {
|
||
fetchImpl: never,
|
||
timeoutMs: 4_000,
|
||
setTimeoutImpl: (callback) => {
|
||
fire = callback;
|
||
return 1;
|
||
},
|
||
clearTimeoutImpl: () => {},
|
||
});
|
||
assert.ok(fire);
|
||
fire?.();
|
||
assert.deepEqual(await late, { turns: [], snapshot: null, complete: false, failure: { status: "timeout" } });
|
||
});
|