fix(rectification): reset fresh cases to reported time

This commit is contained in:
Jesse_Chen
2026-08-15 17:50:51 +08:00
parent b0a1174eae
commit 075c62e579
17 changed files with 878 additions and 256 deletions
+30
View File
@@ -3273,3 +3273,33 @@
- 防复发:任何 revision state 快路径都必须验证可读性,并保留容器镜像 revision 的只读回退,不得仅以路径存在作为可消费条件。 - 防复发:任何 revision state 快路径都必须验证可读性,并保留容器镜像 revision 的只读回退,不得仅以路径存在作为可消费条件。
- 相关记录:BUG-083、BUG-192 - 相关记录:BUG-083、BUG-192
- 修复版本:本次 staging 集成提交(精确 SHA 以远端 staging 与部署结果为准) - 修复版本:本次 staging 集成提交(精确 SHA 以远端 staging 与部署结果为准)
## BUG-194 | 生时校正焦点已写入却被前端误报为未完成
- 状态:resolved(本地候选,待 forward migration、staging 发布与登录态业务验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:V10 生时校正 opening/message 回合的 conversation focus 持久化、运行完成状态、刷新后的 Activity receipt。
- 用户现象:Agent 已正常提出首个校正问题,但界面同时显示“设置对话焦点未完成,当前进度已保留”;刷新后失败步骤又可能从执行凭证中消失。
- 触发条件:`set_agentic_rectification_conversation_focus` 成功插入记录后返回精简字段,而 TypeScript 工具层按完整 focus row 解析;或 set-focus 真失败后,runner 仍允许已有回答文本进入 completed 路径。
- 根因:已部署 RPC 的创建返回值只有 `focus_id/status/idempotent`,与 `parseConversationFocus` 要求的 `id/case_id/question_id/intent/...` 合同不一致,导致成功写入后抛出 `invalid_focus`。同时 turn completion 没有把 set-focus 的最终失败状态作为阻断条件,旧 receipt 也只聚合 completed tools,造成实时与刷新后的失败状态不一致。
- 修复:新增 forward-only migration,使创建和幂等分支统一返回完整 `focus` row 与 `idempotent`set-focus 最终失败时 turn fail closed 为 retryable、不得发送完成回答或结算;turn receipt 按选定 attempt 聚合每个工具最新 terminal 状态并公开安全的 `tool_activities`,客户端使用同一 reducer 恢复 failed tool 与 methods。
- 验证:聚焦回归覆盖 RPC 返回合同、失败后不得 completed、同 attempt 重试成功、receipt attempt 隔离、failed tool 持久化及刷新前后 Activity 一致性;与初始化、Case 和 migration 回归合并运行 203 passed、0 failed。远端 migration 尚未应用,仍需 staging 登录态验证最终 NDJSON、持久化 turn/receipt 与免费 opening 计费不变量。
- 防复发:数据库 RPC 返回结构必须与 TypeScript parser 共用合同测试;提出用户可见主问题前必须成功持久化对应 focus;完成凭证不得只记录成功工具或从 Agent 文本反推执行状态。
- 相关记录:BUG-176、BUG-181、BUG-185、BUG-186
- 修复版本:本次功能分支提交(精确 SHA 以提交、远程分支与 staging 发布结果为准)
## BUG-195 | 新建生时校正错误继承已采用时间和用户误差范围
- 状态:resolved(本地候选,待 staging 发布与登录态业务验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:初始化出生时间采集、`homepage/new` 生时校正 Case 基线与候选搜索范围、历史 Session 恢复。
- 用户现象:用户再次新建校正时,系统可能以上一次采用的分钟而不是最初填报时间为中心,并继续继承旧的前后误差;初始化页面还要求用户选择误差分钟或大致时段。
- 触发条件:Profile 同时存在 `reported_birth_time`、历史 `active_birth_time` 和 uncertainty,或只有 period/unknown 声明时创建 fresh Case。
- 根因:fresh Case 的范围推导优先使用 `active_birth_time`,再直接读取 Profile uncertainty;初始化模型把用户声明误差和引擎搜索窗口混为同一字段,并允许用 period 或全天范围代替具体初始时间。
- 修复:初始化 UI 只采集一个具体 `reported_birth_time`,不再提供误差分钟、大致时段、范围线索或跳过入口;新填报的准确时间保存为 `reported + 0/0`,不自动宣称引擎 confirmed。`homepage/new` 只查询和使用 `reported_birth_time`,忽略历史 active minute、uncertainty 与 period;没有合法 reported time 时在调用创建 RPC 前以 `profile_incomplete` fail closed。Case 扫描所需的可移动窗口改为独立的服务器执行策略,目前以填报时间为中心使用前后 15 分钟,不再伪装成用户声明;`intent=session` 继续恢复历史 Case 自身冻结的 baseline/range。
- 验证:回归覆盖新初始化只展示单一时间输入、`0/0` 持久化但不确认、旧 uncertainty 不影响 fresh range、旧 active minute 不进入 baseline、period/unknown/无具体时间 legacy profile 不得新建、失败前不调用 RPC,以及 session 恢复不读取当前 Profile;与 Focus、receipt 和 migration 回归合并运行 203 passed、0 failed。
- 防复发:`reported_birth_time` 是 fresh Case 唯一用户时间基线;`active_birth_time` 只表示已采用的当前排盘时间,不能反向改写新校正起点;用户声明字段、服务器搜索策略与最终 confirmed truth 必须保持分层。
- 相关记录:BUG-127、BUG-177、BUG-187
- 修复版本:本次功能分支提交(精确 SHA 以提交、远程分支与 staging 发布结果为准)
@@ -123,6 +123,11 @@ function turnReceipt(
engine_version: receipt.engineVersion, engine_version: receipt.engineVersion,
status: receiptStatusFromTurn(receipt.status), status: receiptStatusFromTurn(receipt.status),
phases: receipt.phases.map((phase) => phase.phase), phases: receipt.phases.map((phase) => phase.phase),
tool_activities: receipt.toolActivities.map((activity) => ({
tool: activity.tool,
status: activity.status,
methods: activity.methods,
})),
tools: receipt.tools, tools: receipt.tools,
methods: receipt.methods, methods: receipt.methods,
started_at: receipt.startedAt, started_at: receipt.startedAt,
+6 -132
View File
@@ -6,11 +6,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent"; import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
import { import {
birthTimeDisplayState, birthTimeDisplayState,
birthTimePeriodOptions, birthTimeSourceDefaults,
birthTimeSourceOptions, birthTimeSourceOptions,
type BirthTimeDraft, type BirthTimeDraft,
type BirthTimeDraftPatch, type BirthTimeDraftPatch,
type BirthTimeSource,
} from "@/lib/birth-time-intake-model"; } from "@/lib/birth-time-intake-model";
type BirthTimeIntakeProps = { type BirthTimeIntakeProps = {
@@ -66,47 +65,11 @@ function BirthClockSelect({ value, onChange }: BirthClockSelectProps) {
); );
} }
const sourceDefaults = {
hospital_record: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
},
family_exact: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
},
approximate: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
},
period_only: {
reportedTime: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
unknown: {
reportedTime: "",
birthTimePeriod: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
} as const satisfies Record<Exclude<BirthTimeSource, "" | "legacy_import">, BirthTimeDraftPatch>;
export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) { export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {
const groupId = useId(); const groupId = useId();
const source = value.birthTimeSource; const source = value.birthTimeSource;
const isConfirmed = value.birthTimeStatus === "confirmed"; const isConfirmed = value.birthTimeStatus === "confirmed";
const displayState = birthTimeDisplayState(value); const displayState = birthTimeDisplayState(value);
const knowledgeMode = source === "period_only" || source === "unknown"
? "uncertain"
: source ? "exact" : "";
const usesClockTime = source === "hospital_record" const usesClockTime = source === "hospital_record"
|| source === "family_exact" || source === "family_exact"
|| source === "approximate" || source === "approximate"
@@ -151,20 +114,16 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
)} )}
{!isConfirmed && <fieldset className="birth-time-source-fieldset"> {!isConfirmed && <fieldset className="birth-time-source-fieldset">
<legend></legend> <legend></legend>
<p className="birth-time-source-intro"></p> <p className="birth-time-source-intro"></p>
<div className="birth-time-source-list"> <div className="birth-time-source-list">
{birthTimeSourceOptions.map((option) => ( {birthTimeSourceOptions.map((option) => (
<label <label
className={`birth-time-source-option ${option.value === "family_exact" className={`birth-time-source-option ${source === option.value ? "is-selected" : ""}`}
? knowledgeMode === "exact" ? "is-selected" : ""
: knowledgeMode === "uncertain" ? "is-selected" : ""}`}
key={option.value} key={option.value}
> >
<input <input
checked={option.value === "family_exact" checked={source === option.value}
? knowledgeMode === "exact"
: knowledgeMode === "uncertain"}
name={`birth-time-source-${groupId}`} name={`birth-time-source-${groupId}`}
type="radio" type="radio"
value={option.value} value={option.value}
@@ -172,7 +131,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
birthTimeSource: option.value, birthTimeSource: option.value,
birthTimeStatus: "reported", birthTimeStatus: "reported",
time: "", time: "",
...sourceDefaults[option.value], ...birthTimeSourceDefaults[option.value],
})} })}
/> />
<span> <span>
@@ -197,91 +156,6 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
{source === "hospital_record" && ( {source === "hospital_record" && (
<p className="birth-time-detail-note"> 2 D9 / D10 </p> <p className="birth-time-detail-note"> 2 D9 / D10 </p>
)} )}
{source === "approximate" && (
<label>
<span></span>
<Select
value={value.uncertaintyBeforeMinutes?.toString() ?? ""}
onValueChange={(nextValue) => {
if (nextValue === null) return;
const minutes = Number(nextValue);
onPatch({ uncertaintyBeforeMinutes: minutes, uncertaintyAfterMinutes: minutes });
}}
>
<SelectTrigger aria-label="可能误差">
<SelectValue placeholder="选择误差范围">
{(selectedValue) => selectedValue ? `前后 ${selectedValue} 分钟` : "选择误差范围"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[15, 30, 60].map((minutes) => (
<SelectItem key={minutes} value={minutes.toString()}> {minutes} </SelectItem>
))}
</SelectContent>
</Select>
</label>
)}
</div>
)}
{source === "period_only" && (
<div className="birth-time-detail-grid birth-time-period-details onboarding-card-reveal">
<label>
<span></span>
<Select
required
value={value.birthTimePeriod || null}
onValueChange={(nextValue) => {
if (typeof nextValue === "string") onPatch({ birthTimePeriod: nextValue });
}}
>
<SelectTrigger aria-label="最接近的时间范围">
<SelectValue placeholder="请选择大致时段">
{(selectedValue) => birthTimePeriodOptions.find((option) => option.value === selectedValue)?.label ?? "请选择大致时段"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{birthTimePeriodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label>
<span></span>
<textarea
maxLength={240}
placeholder="例如:天刚亮、午饭前后、家人记得大约 6—8 点"
rows={2}
value={value.birthTimeClue}
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
/>
</label>
<button
className="button-secondary birth-time-skip-button"
type="button"
onClick={() => onPatch({
birthTimeSource: "unknown",
reportedTime: "",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "reported",
time: "",
})}
></button>
</div>
)}
{source === "unknown" && (
<div className="birth-time-detail-note onboarding-card-reveal" role="status">
<p>使</p>
<button
className="button-secondary"
type="button"
onClick={() => onPatch({ birthTimeSource: "period_only", birthTimeStatus: "reported" })}
></button>
</div> </div>
)} )}
</div> </div>
@@ -36,6 +36,11 @@ type PersistedTurn = Readonly<{
receipt?: Readonly<{ receipt?: Readonly<{
status: string; status: string;
phases: readonly string[]; phases: readonly string[];
tool_activities?: readonly Readonly<{
tool: string;
status: string;
methods?: readonly string[];
}>[];
tools: readonly string[]; tools: readonly string[];
methods?: readonly string[]; methods?: readonly string[];
skill_name?: string; skill_name?: string;
@@ -94,6 +99,21 @@ const ACTIVE_TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView { function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] }; if (!receipt) return { steps: [], methods: [] };
if (Array.isArray(receipt.tool_activities)) {
let state = createRectificationActivityReceiptState();
for (const activity of receipt.tool_activities) {
if (!isPublicRectificationTool(activity.tool)
|| (activity.status !== "completed" && activity.status !== "failed")) continue;
state = reduceRectificationActivityReceipt(state, {
tool: activity.tool,
status: activity.status,
methods: activity.status === "completed" && Array.isArray(activity.methods)
? activity.methods.filter(isPublicRectificationMethod)
: [],
});
}
return receiptFromRectificationActivityState(state);
}
return { return {
steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))], steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))],
methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))], methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))],
+2 -2
View File
@@ -121,8 +121,8 @@ export const accountProfilePatchSchema = z.object({
ensureNoPeriod(); ensureNoPeriod();
} else if (source === "family_exact") { } else if (source === "family_exact") {
if (!time) addIssue("reported_birth_time", "家人记忆需要具体时间"); if (!time) addIssue("reported_birth_time", "家人记忆需要具体时间");
if (![5, 10, 15].includes(before ?? -1) || before !== after) { if (![0, 5, 10, 15].includes(before ?? -1) || before !== after) {
addIssue("uncertainty_before_minutes", "家人记忆误差必须前后 5、10 或 15 分钟"); addIssue("uncertainty_before_minutes", "准确时间填报必须前后一致");
} }
ensureNoPeriod(); ensureNoPeriod();
} else if (source === "approximate") { } else if (source === "approximate") {
+46 -10
View File
@@ -83,10 +83,42 @@ export function formatBirthDate(value: Date): string {
} }
export const birthTimeSourceOptions = [ export const birthTimeSourceOptions = [
{ value: "family_exact", label: "我知道准确出生时间", hint: "填写后直接用于当前分析,无需先做生时校正" }, { value: "family_exact", label: "我知道准确出生时间", hint: "保存为初始化填报时间,不会自动标记为引擎确认" },
{ value: "period_only", label: "我不确定准确时间", hint: "告诉我们大致时段;完全不清楚也可以直接跳过" },
] as const; ] as const;
export const birthTimeSourceDefaults = {
hospital_record: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
},
family_exact: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
},
approximate: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
},
period_only: {
reportedTime: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
unknown: {
reportedTime: "",
birthTimePeriod: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
} as const satisfies Record<Exclude<BirthTimeSource, "" | "legacy_import">, BirthTimeDraftPatch>;
export const birthTimePeriodOptions = [ export const birthTimePeriodOptions = [
{ value: "early_morning", label: "凌晨 / 清晨(04:00—07:59" }, { value: "early_morning", label: "凌晨 / 清晨(04:00—07:59" },
{ value: "morning", label: "上午(08:00—11:59" }, { value: "morning", label: "上午(08:00—11:59" },
@@ -163,12 +195,14 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
return isBirthClockTime(draft.reportedTime || draft.time); return isBirthClockTime(draft.reportedTime || draft.time);
case "family_exact": case "family_exact":
return isBirthClockTime(draft.reportedTime) return isBirthClockTime(draft.reportedTime)
&& [5, 10, 15].includes(draft.uncertaintyBeforeMinutes ?? -1) && draft.uncertaintyBeforeMinutes === 0
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes; && draft.uncertaintyAfterMinutes === 0;
case "approximate": case "approximate":
return isBirthClockTime(draft.reportedTime) return isBirthClockTime(draft.reportedTime)
&& [15, 30, 60].includes(draft.uncertaintyBeforeMinutes ?? -1) && (draft.uncertaintyBeforeMinutes === null
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes; || [15, 30, 60].includes(draft.uncertaintyBeforeMinutes))
&& (draft.uncertaintyAfterMinutes === null
|| draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes);
case "period_only": case "period_only":
return birthTimePeriodOptions.some((option) => option.value === draft.birthTimePeriod) return birthTimePeriodOptions.some((option) => option.value === draft.birthTimePeriod)
&& !draft.reportedTime && !draft.reportedTime
@@ -267,9 +301,11 @@ export function birthTimePersistenceValues(draft: BirthTimeDraft) {
: null; : null;
const uncertainty = draft.birthTimeSource === "hospital_record" const uncertainty = draft.birthTimeSource === "hospital_record"
? 2 ? 2
: draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate" : draft.birthTimeSource === "family_exact"
? draft.uncertaintyBeforeMinutes ? 0
: null; : draft.birthTimeSource === "approximate"
? draft.uncertaintyBeforeMinutes ?? birthTimeSourceDefaults.approximate.uncertaintyBeforeMinutes
: null;
return { return {
reported_birth_time: reportedTime, reported_birth_time: reportedTime,
birth_time_source: draft.birthTimeSource || null, birth_time_source: draft.birthTimeSource || null,
@@ -289,7 +325,7 @@ export function describeBirthTimeDraft(draft: BirthTimeDraft) {
case "family_exact": case "family_exact":
return `${date} ${draft.reportedTime}(填报准确时间)`; return `${date} ${draft.reportedTime}(填报准确时间)`;
case "approximate": case "approximate":
return `${date},约 ${draft.reportedTime}(前后 ${draft.uncertaintyBeforeMinutes} 分钟)`; return `${date},约 ${draft.reportedTime}`;
case "period_only": case "period_only":
return `${date}${periodLabels[draft.birthTimePeriod]}${draft.birthTimeClue.trim() ? `${draft.birthTimeClue.trim()}` : ""}`; return `${date}${periodLabels[draft.birthTimePeriod]}${draft.birthTimeClue.trim() ? `${draft.birthTimeClue.trim()}` : ""}`;
case "unknown": case "unknown":
@@ -14,7 +14,7 @@ type ToolTerminalStatus = "completed" | "failed";
export type RectificationActivityReceiptState = Readonly<{ export type RectificationActivityReceiptState = Readonly<{
completedSteps: readonly PublicRectificationTool[]; completedSteps: readonly PublicRectificationTool[];
methods: readonly PublicRectificationMethod[]; methodsByTool: Readonly<Partial<Record<PublicRectificationTool, readonly PublicRectificationMethod[]>>>;
terminalStatus: Readonly<Partial<Record<PublicRectificationTool, ToolTerminalStatus>>>; terminalStatus: Readonly<Partial<Record<PublicRectificationTool, ToolTerminalStatus>>>;
failureOrder: readonly PublicRectificationTool[]; failureOrder: readonly PublicRectificationTool[];
}>; }>;
@@ -28,7 +28,7 @@ type ReceiptActivityEvent = Readonly<{
export function createRectificationActivityReceiptState(): RectificationActivityReceiptState { export function createRectificationActivityReceiptState(): RectificationActivityReceiptState {
return { return {
completedSteps: [], completedSteps: [],
methods: [], methodsByTool: {},
terminalStatus: {}, terminalStatus: {},
failureOrder: [], failureOrder: [],
}; };
@@ -46,7 +46,10 @@ export function reduceRectificationActivityReceipt(
completedSteps: state.completedSteps.includes(event.tool) completedSteps: state.completedSteps.includes(event.tool)
? state.completedSteps ? state.completedSteps
: [...state.completedSteps, event.tool], : [...state.completedSteps, event.tool],
methods: [...new Set([...state.methods, ...(event.methods ?? [])])], methodsByTool: {
...state.methodsByTool,
[event.tool]: [...new Set(event.methods ?? [])],
},
terminalStatus, terminalStatus,
failureOrder: state.failureOrder.filter((tool) => tool !== event.tool), failureOrder: state.failureOrder.filter((tool) => tool !== event.tool),
}; };
@@ -54,6 +57,8 @@ export function reduceRectificationActivityReceipt(
return { return {
...state, ...state,
completedSteps: state.completedSteps.filter((tool) => tool !== event.tool),
methodsByTool: { ...state.methodsByTool, [event.tool]: [] },
terminalStatus, terminalStatus,
failureOrder: [ failureOrder: [
...state.failureOrder.filter((tool) => tool !== event.tool), ...state.failureOrder.filter((tool) => tool !== event.tool),
@@ -70,7 +75,7 @@ export function receiptFromRectificationActivityState(
.find((tool) => state.terminalStatus[tool] === "failed"); .find((tool) => state.terminalStatus[tool] === "failed");
return { return {
steps: [...state.completedSteps], steps: [...state.completedSteps],
methods: [...state.methods], methods: [...new Set(state.completedSteps.flatMap((tool) => state.methodsByTool[tool] ?? []))],
...(failedTool ? { failedTool } : {}), ...(failedTool ? { failedTool } : {}),
}; };
} }
@@ -96,6 +96,7 @@ const RETRYABLE_ERROR_CODES = new Set([
"skill_not_loaded", "skill_not_loaded",
"skill_not_bound", "skill_not_bound",
"case_not_loaded", "case_not_loaded",
"focus_persistence_failed",
]); ]);
function first(value: unknown): unknown { function first(value: unknown): unknown {
@@ -126,6 +127,7 @@ function safeErrorCode(error: unknown): string {
"skill_not_bound", "skill_not_bound",
"case_not_loaded", "case_not_loaded",
"repeated_tool_call", "repeated_tool_call",
"focus_persistence_failed",
]) { ]) {
if (message.includes(code)) return code; if (message.includes(code)) return code;
} }
@@ -327,6 +329,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (!outcome.ok) { if (!outcome.ok) {
await billing.release(); await billing.release();
await finalizeTurn(outcome.status, null, outcome.attemptId, null); await finalizeTurn(outcome.status, null, outcome.attemptId, null);
for (const event of outcome.events) await emit(event);
await emit({ type: "run.failed" }); await emit({ type: "run.failed" });
return { return {
ok: false, ok: false,
@@ -334,8 +337,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
turnStatus: outcome.status, turnStatus: outcome.status,
skillLoaded: false, skillLoaded: false,
answerText: "", answerText: "",
phases: [], phases: outcome.phases,
toolsUsed: [], toolsUsed: outcome.toolsUsed,
errorCode: outcome.errorCode, errorCode: outcome.errorCode,
}; };
} }
@@ -439,6 +442,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const phases: string[] = []; const phases: string[] = [];
const toolsUsed = new Set<string>(); const toolsUsed = new Set<string>();
const events: PublicStreamEvent[] = []; const events: PublicStreamEvent[] = [];
const toolTerminalStatus = new Map<string, "completed" | "failed">();
const emittedKeys = new Set<string>(); const emittedKeys = new Set<string>();
const repeatedCalls = new Map<string, number>(); const repeatedCalls = new Map<string, number>();
let phaseSequence = 0; let phaseSequence = 0;
@@ -491,7 +495,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
} }
const activityEvent = mapStreamChunkToActivity(chunk as never); const activityEvent = mapStreamChunkToActivity(chunk as never);
if (activityEvent) events.push(activityEvent); if (activityEvent) {
events.push(activityEvent);
if (activityEvent.status === "completed" || activityEvent.status === "failed") {
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
}
const phaseEvent = mapStreamChunkToPhase(chunk as never); const phaseEvent = mapStreamChunkToPhase(chunk as never);
if (phaseEvent) { if (phaseEvent) {
if (phaseEvent.type === "skill.bound") { if (phaseEvent.type === "skill.bound") {
@@ -542,6 +551,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted"); if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
if (!finished) return failedAttempt(attemptId, "stream_unfinished"); if (!finished) return failedAttempt(attemptId, "stream_unfinished");
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream"); if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
return {
ok: false,
status: "retryable",
errorCode: "focus_persistence_failed",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 })); const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
await recordPhase("answer.composed"); await recordPhase("answer.composed");
@@ -102,54 +102,22 @@ function numberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null; return typeof value === "number" && Number.isFinite(value) ? value : null;
} }
const periodRanges: Readonly<Record<string, { start_time: string; end_time: string }>> = {
early_morning: { start_time: "04:00", end_time: "07:59" },
morning: { start_time: "08:00", end_time: "11:59" },
afternoon: { start_time: "12:00", end_time: "17:59" },
evening: { start_time: "18:00", end_time: "22:59" },
late_night: { start_time: "23:00", end_time: "03:59" },
};
function shiftedTime(time: string, offsetMinutes: number): string { function shiftedTime(time: string, offsetMinutes: number): string {
const [hour = 0, minute = 0] = time.split(":").map(Number); const [hour = 0, minute = 0] = time.split(":").map(Number);
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440; const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`; return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
} }
function fallbackUncertainty(source: string): number { // This is an engine execution boundary, not a user-declared uncertainty. Fresh
if (source === "hospital_record" || source === "hospital") return 2; // Cases always start from reported_birth_time with enough room to rectify.
if (source === "family_exact" || source === "family_clear") return 15; const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15;
if (source === "approximate" || source === "family_vague") return 60;
return 2;
}
function deriveCandidateRange(input: { function deriveCandidateRange(reportedTime: string | null): { start_time: string; end_time: string } {
activeTime: string | null; if (!reportedTime) throw new RectificationCaseServiceError("profile_incomplete");
reportedTime: string | null; return {
source: string; start_time: shiftedTime(reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES),
period: string | null; end_time: shiftedTime(reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES),
uncertaintyBefore: number | null; };
uncertaintyAfter: number | null;
}): { start_time: string; end_time: string } {
const referenceTime = input.activeTime ?? input.reportedTime;
if (referenceTime) {
const fallback = fallbackUncertainty(input.source);
return {
start_time: shiftedTime(referenceTime, -(input.uncertaintyBefore ?? fallback)),
end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback),
};
}
if (input.source === "period_only" || input.source === "legacy_import") {
const period = input.period ? periodRanges[input.period] : undefined;
if (period) return period;
if (input.source === "period_only") {
throw new RectificationCaseServiceError("profile_incomplete");
}
}
if (input.source === "unknown" || input.source === "legacy_import") {
return { start_time: "00:00", end_time: "23:59" };
}
throw new RectificationCaseServiceError("profile_incomplete");
} }
function baselineFingerprint(baseline: V9BaselineSnapshot): string { function baselineFingerprint(baseline: V9BaselineSnapshot): string {
@@ -177,7 +145,7 @@ export async function loadV9RectificationProfile(
const { data, error } = await accounting const { data, error } = await accounting
.from("profiles") .from("profiles")
.select( .select(
"birth_date,birth_place_label,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset", "birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset",
) )
.eq("id", userId) .eq("id", userId)
.single(); .single();
@@ -192,7 +160,6 @@ export async function loadV9RectificationProfile(
const timezoneOffset = numberOrNull(row.timezone_offset); const timezoneOffset = numberOrNull(row.timezone_offset);
const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : ""; const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : "";
const reportedTime = timeValue(row.reported_birth_time); const reportedTime = timeValue(row.reported_birth_time);
const activeTime = timeValue(row.active_birth_time);
const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null; const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null;
const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes); const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes);
const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes); const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes);
@@ -210,7 +177,8 @@ export async function loadV9RectificationProfile(
birth_time_source: source, birth_time_source: source,
birth_time_period: period, birth_time_period: period,
reported_birth_time: reportedTime, reported_birth_time: reportedTime,
active_birth_time: activeTime, // A fresh Case must never inherit an accepted/active minute as its new baseline.
active_birth_time: null,
uncertainty_before_minutes: uncertaintyBefore, uncertainty_before_minutes: uncertaintyBefore,
uncertainty_after_minutes: uncertaintyAfter, uncertainty_after_minutes: uncertaintyAfter,
}; };
@@ -219,14 +187,7 @@ export async function loadV9RectificationProfile(
userId, userId,
baseline, baseline,
baselineFingerprint: baselineFingerprint(baseline), baselineFingerprint: baselineFingerprint(baseline),
candidateRange: deriveCandidateRange({ candidateRange: deriveCandidateRange(reportedTime),
activeTime,
reportedTime,
source,
period,
uncertaintyBefore,
uncertaintyAfter,
}),
}; };
} }
@@ -820,6 +820,11 @@ export type V9TurnReceipt = Readonly<{
skillVersion: string; skillVersion: string;
engineVersion: string | null; engineVersion: string | null;
phases: readonly Readonly<{ phase: string; tool: string | null }>[]; phases: readonly Readonly<{ phase: string; tool: string | null }>[];
toolActivities: readonly Readonly<{
tool: PublicRectificationTool;
status: "completed" | "failed";
methods: readonly PublicRectificationMethod[];
}>[];
tools: readonly string[]; tools: readonly string[];
methods: readonly PublicRectificationMethod[]; methods: readonly PublicRectificationMethod[];
startedAt: string; startedAt: string;
@@ -843,6 +848,21 @@ export async function loadV9TurnReceipt(
const phaseRow = item as Record<string, unknown>; const phaseRow = item as Record<string, unknown>;
return [{ phase: String(phaseRow.phase ?? ""), tool: rowText(phaseRow.tool) }]; return [{ phase: String(phaseRow.phase ?? ""), tool: rowText(phaseRow.tool) }];
}); });
const toolActivities = rowArray(row.tool_activities)
.flatMap<V9TurnReceipt["toolActivities"][number]>((item) => {
const activity = rowObject(item);
if (!activity) return [];
const tool = activity.tool;
const status = activity.status;
if (!isPublicRectificationTool(tool) || (status !== "completed" && status !== "failed")) return [];
return [{
tool,
status,
methods: status === "completed"
? rowArray(activity.methods).filter(isPublicRectificationMethod)
: [],
}];
});
return { return {
turnId: row.turn_id, turnId: row.turn_id,
attemptId: rowText(row.attempt_id), attemptId: rowText(row.attempt_id),
@@ -851,6 +871,7 @@ export async function loadV9TurnReceipt(
skillVersion: String(row.skill_version ?? ""), skillVersion: String(row.skill_version ?? ""),
engineVersion: rowText(row.engine_version), engineVersion: rowText(row.engine_version),
phases, phases,
toolActivities,
tools: rowArray(row.tools).map((item) => String(item)), tools: rowArray(row.tools).map((item) => String(item)),
methods: rowArray(row.methods).filter(isPublicRectificationMethod), methods: rowArray(row.methods).filter(isPublicRectificationMethod),
startedAt: String(row.started_at ?? ""), startedAt: String(row.started_at ?? ""),
@@ -0,0 +1,235 @@
-- Rectification focus RPC and persisted activity receipt consistency.
-- Created 2026-08-15. Forward-only repair for the deployed V10 contract.
begin;
create or replace function public.set_agentic_rectification_conversation_focus(
p_user_id uuid,
p_case_id uuid,
p_question_id text,
p_intent text,
p_target_evidence_id uuid,
p_target_domain text,
p_target_kind text,
p_expected_answer_schema jsonb
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_focus public.agentic_rectification_conversation_focuses%rowtype;
begin
if p_user_id is null or p_case_id is null
or length(btrim(coalesce(p_question_id, ''))) = 0
or length(btrim(coalesce(p_intent, ''))) = 0
or p_expected_answer_schema is null
or jsonb_typeof(p_expected_answer_schema) <> 'object' then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id
for update;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
end if;
if p_target_evidence_id is not null and not exists (
select 1 from public.agentic_rectification_evidence
where id = p_target_evidence_id and case_id = p_case_id
) then
raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001';
end if;
select * into v_focus
from public.agentic_rectification_conversation_focuses
where case_id = p_case_id and question_id = p_question_id;
if found then
if v_focus.status = 'active'
and v_focus.intent = p_intent
and v_focus.target_evidence_id is not distinct from p_target_evidence_id
and v_focus.target_domain is not distinct from p_target_domain
and v_focus.target_kind is not distinct from p_target_kind
and v_focus.expected_answer_schema = p_expected_answer_schema then
return jsonb_build_object(
'focus', to_jsonb(v_focus),
'idempotent', true
);
end if;
raise exception 'agentic_rectification_focus_idempotency_conflict' using errcode = 'P0001';
end if;
update public.agentic_rectification_conversation_focuses
set status = 'superseded',
resolved_at = pg_catalog.now(),
updated_at = pg_catalog.now()
where case_id = p_case_id and status = 'active';
insert into public.agentic_rectification_conversation_focuses (
case_id, question_id, intent, target_evidence_id, target_domain, target_kind,
expected_answer_schema, status, asked_at
) values (
p_case_id, p_question_id, p_intent, p_target_evidence_id, p_target_domain, p_target_kind,
p_expected_answer_schema, 'active', pg_catalog.now()
) returning * into v_focus;
return jsonb_build_object(
'focus', to_jsonb(v_focus),
'idempotent', false
);
end;
$$;
revoke all on function public.set_agentic_rectification_conversation_focus(
uuid, uuid, text, text, uuid, text, text, jsonb
) from public, anon, authenticated;
grant execute on function public.set_agentic_rectification_conversation_focus(
uuid, uuid, text, text, uuid, text, text, jsonb
) to service_role;
create or replace function public.get_agentic_rectification_turn_receipt(
p_user_id uuid,
p_case_id uuid,
p_turn_id uuid
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_turn public.agentic_rectification_turns%rowtype;
v_case public.agentic_rectification_cases%rowtype;
v_attempt_id uuid;
v_phases jsonb;
v_tool_activities jsonb;
v_tools jsonb;
v_methods jsonb;
v_engine_version text;
begin
if p_user_id is null or p_case_id is null or p_turn_id is null then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
select * into v_turn
from public.agentic_rectification_turns
where id = p_turn_id and case_id = p_case_id;
if not found then
raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001';
end if;
v_attempt_id := v_turn.successful_attempt_id;
if v_attempt_id is null then
select id into v_attempt_id
from public.agentic_rectification_run_attempts
where turn_id = p_turn_id
order by attempt_number desc
limit 1;
end if;
select coalesce(jsonb_agg(
jsonb_build_object('phase', rp.phase, 'tool', rp.tool_name)
order by rp.sequence, rp.created_at, rp.id
), '[]'::jsonb) into v_phases
from public.agentic_rectification_run_phases rp
where rp.turn_id = p_turn_id
and (
(v_attempt_id is not null and rp.attempt_id = v_attempt_id)
or (v_attempt_id is null and rp.attempt_id is null)
);
with latest_terminal as materialized (
select distinct on (tr.tool_name)
tr.id,
tr.tool_name,
tr.status,
tr.executed_methods,
tr.started_at
from public.agentic_rectification_tool_receipts tr
where tr.turn_id = p_turn_id
and tr.status in ('completed', 'failed')
and (
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
or (v_attempt_id is null and tr.attempt_id is null)
)
order by tr.tool_name, tr.started_at desc, tr.id desc
)
select
coalesce(jsonb_agg(
jsonb_build_object(
'tool', terminal.tool_name,
'status', terminal.status,
'methods', case
when terminal.status = 'completed' then terminal.executed_methods
else '[]'::jsonb
end
) order by terminal.started_at, terminal.id
), '[]'::jsonb),
coalesce(jsonb_agg(terminal.tool_name order by terminal.started_at, terminal.id)
filter (where terminal.status = 'completed'), '[]'::jsonb)
into v_tool_activities, v_tools
from latest_terminal terminal;
with latest_terminal as materialized (
select distinct on (tr.tool_name)
tr.tool_name,
tr.status,
tr.executed_methods
from public.agentic_rectification_tool_receipts tr
where tr.turn_id = p_turn_id
and tr.status in ('completed', 'failed')
and (
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
or (v_attempt_id is null and tr.attempt_id is null)
)
order by tr.tool_name, tr.started_at desc, tr.id desc
)
select coalesce(jsonb_agg(method order by method), '[]'::jsonb) into v_methods
from (
select distinct jsonb_array_elements_text(terminal.executed_methods) as method
from latest_terminal terminal
where terminal.status = 'completed'
) methods;
select max(tr.engine_version) into v_engine_version
from public.agentic_rectification_tool_receipts tr
where tr.turn_id = p_turn_id and tr.engine_version is not null
and (
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
or (v_attempt_id is null and tr.attempt_id is null)
);
return jsonb_build_object(
'turn_id', v_turn.id,
'attempt_id', v_attempt_id,
'status', v_turn.status,
'skill_name', v_case.skill_name,
'skill_version', v_case.skill_version,
'engine_version', v_engine_version,
'phases', v_phases,
'tool_activities', v_tool_activities,
'tools', v_tools,
'methods', v_methods,
'started_at', v_turn.created_at,
'completed_at', v_turn.completed_at
);
end;
$$;
revoke all on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid)
from public, anon, authenticated;
grant execute on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid)
to service_role;
commit;
+4 -3
View File
@@ -57,8 +57,8 @@ test("profile patch schema validates calendar, clock, source requirements, and l
birth_time_source: "family_exact", birth_time_source: "family_exact",
birth_time_period: null, birth_time_period: null,
birth_time_clue: null, birth_time_clue: null,
uncertainty_before_minutes: 10, uncertainty_before_minutes: 0,
uncertainty_after_minutes: 10, uncertainty_after_minutes: 0,
country_code: "CN", country_code: "CN",
province_code: "130000", province_code: "130000",
city_code: "130400", city_code: "130400",
@@ -69,9 +69,10 @@ test("profile patch schema validates calendar, clock, source requirements, and l
}; };
assert.equal(accountProfilePatchSchema.safeParse(valid).success, true); assert.equal(accountProfilePatchSchema.safeParse(valid).success, true);
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, uncertainty_before_minutes: 10, uncertainty_after_minutes: 10 }).success, true);
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, birth_date: "2001-02-29" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, birth_date: "2001-02-29" }).success, false);
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, reported_birth_time: "24:00" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, reported_birth_time: "24:00" }).success, false);
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, uncertainty_before_minutes: 7 }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, uncertainty_before_minutes: 7, uncertainty_after_minutes: 7 }).success, false);
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, latitude: 91 }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, latitude: 91 }).success, false);
assert.equal(accountProfilePatchSchema.safeParse({ reported_birth_time: "05:30" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ reported_birth_time: "05:30" }).success, false);
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, longitude: null }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, longitude: null }).success, false);
+42 -7
View File
@@ -6,6 +6,8 @@ import {
assistantIntentCopy, assistantIntentCopy,
birthTimeDisplayState, birthTimeDisplayState,
birthTimePersistenceValues, birthTimePersistenceValues,
birthTimeSourceDefaults,
birthTimeSourceOptions,
describeBirthTimeDraft, describeBirthTimeDraft,
formatBirthDate, formatBirthDate,
isDeclaredBirthProfileComplete, isDeclaredBirthProfileComplete,
@@ -49,10 +51,39 @@ test("birth time intake requires only the fields selected by the source", () =>
assert.equal(isBirthTimeDraftReady(hospital), true); assert.equal(isBirthTimeDraftReady(hospital), true);
assert.equal(isBirthTimeDraftReady(period), true); assert.equal(isBirthTimeDraftReady(period), true);
assert.equal(isBirthTimeDraftReady(incompleteApproximate), false); assert.equal(isBirthTimeDraftReady(incompleteApproximate), true);
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true); assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
}); });
test("family exact intake defaults and persists as reported 0/0 without engine confirmation", () => {
assert.deepEqual(birthTimeSourceDefaults.family_exact, {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
});
const declaredExact = {
...emptyDraft,
reportedTime: "05:00",
birthTimeSource: "family_exact",
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
assert.equal(isBirthTimeDraftReady(declaredExact), true);
assert.equal(isBirthTimeReadyForConsultation(declaredExact), false);
assert.deepEqual(birthTimePersistenceValues(declaredExact), {
reported_birth_time: "05:00",
birth_time_source: "family_exact",
birth_time_period: null,
birth_time_clue: null,
uncertainty_before_minutes: 0,
uncertainty_after_minutes: 0,
});
});
test("an unconfirmed candidate working time remains in rectification onboarding", () => { test("an unconfirmed candidate working time remains in rectification onboarding", () => {
const candidate = { const candidate = {
...emptyDraft, ...emptyDraft,
@@ -71,8 +102,8 @@ test("declared birth data completes onboarding without an active or confirmed mi
...emptyDraft, ...emptyDraft,
reportedTime: "05:30", reportedTime: "05:30",
birthTimeSource: "family_exact", birthTimeSource: "family_exact",
uncertaintyBeforeMinutes: 10, uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 10, uncertaintyAfterMinutes: 0,
birthTimeStatus: "reported", birthTimeStatus: "reported",
} satisfies BirthTimeDraft; } satisfies BirthTimeDraft;
const declaredPeriod = { const declaredPeriod = {
@@ -93,8 +124,8 @@ test("declared completeness validates the actual calendar date, clock, source fi
...emptyDraft, ...emptyDraft,
birthTimeSource: "family_exact", birthTimeSource: "family_exact",
reportedTime: "05:30", reportedTime: "05:30",
uncertaintyBeforeMinutes: 10, uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 10, uncertaintyAfterMinutes: 0,
} satisfies BirthTimeDraft; } satisfies BirthTimeDraft;
assert.equal(isDeclaredBirthProfileComplete({ ...exact, date: "2000-02-29" }), true); assert.equal(isDeclaredBirthProfileComplete({ ...exact, date: "2000-02-29" }), true);
@@ -245,7 +276,7 @@ test("birth time intake describes uncertainty without claiming false precision",
uncertaintyAfterMinutes: 30, uncertaintyAfterMinutes: 30,
} satisfies BirthTimeDraft; } satisfies BirthTimeDraft;
assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30(前后 30 分钟)"); assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30");
assert.equal( assert.equal(
assistantIntentCopy("present_saved_candidate_range"), assistantIntentCopy("present_saved_candidate_range"),
"目前只能保存候选范围,还没有足够证据应用到具体分钟。", "目前只能保存候选范围,还没有足够证据应用到具体分钟。",
@@ -277,9 +308,13 @@ test("persisted birth dates normalize database ISO values without accepting inva
assert.equal(normalizePersistedBirthDate("1997-08-08junk"), ""); assert.equal(normalizePersistedBirthDate("1997-08-08junk"), "");
}); });
test("candidate copy does not claim an unconfirmed minute is automatically in use", () => { test("fresh intake only offers a concrete reported time and does not auto-confirm it", () => {
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8"); const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.deepEqual(birthTimeSourceOptions.map((option) => option.value), ["family_exact"]);
assert.doesNotMatch(source, /已用于当前排盘/); assert.doesNotMatch(source, /已用于当前排盘/);
assert.doesNotMatch(source, /可能误差|选择误差范围|最接近的时间范围|大致时段|描述一个时间范围/);
assert.doesNotMatch(source, /birthTimePeriodOptions|birthTimeSource: "period_only"|birthTimeSource: "unknown"/);
assert.match(source, /birthTimeStatus: "reported"/);
assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/); assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/);
}); });
@@ -59,12 +59,36 @@ test("a later failure overrides an earlier completion for the same tool", () =>
}); });
assert.deepEqual(receiptFromRectificationActivityState(state), { assert.deepEqual(receiptFromRectificationActivityState(state), {
steps: ["rectification-read-case"], steps: [],
methods: [], methods: [],
failedTool: "rectification-read-case", failedTool: "rectification-read-case",
}); });
}); });
test("a later failure removes methods contributed by the failed tool", () => {
let state = createRectificationActivityReceiptState();
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-compare-candidates",
status: "completed",
methods: ["d1-rashi", "vimshottari-dasha"],
});
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-read-diagnostics",
status: "completed",
methods: ["d1-rashi", "shadbala"],
});
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-compare-candidates",
status: "failed",
});
assert.deepEqual(receiptFromRectificationActivityState(state), {
steps: ["rectification-read-diagnostics"],
methods: ["d1-rashi", "shadbala"],
failedTool: "rectification-compare-candidates",
});
});
const chatSource = readFileSync( const chatSource = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8", "utf8",
@@ -0,0 +1,161 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
loadV9TurnReceipt,
setV10ConversationFocus,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
} from "../src/lib/rectification-activity-receipt.ts";
import {
CASE_ID,
EVIDENCE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const migrationFilename = "20260815010000_rectification_focus_receipt_consistency.sql";
const migration = readFileSync(
new URL(`../supabase/migrations/${migrationFilename}`, import.meta.url),
"utf8",
);
const migrationCopy = fileURLToPath(
new URL(`../db/migrations/${migrationFilename}`, import.meta.url),
);
function migrationFunction(name: string, nextMarker: string): string {
const start = migration.indexOf(`create or replace function public.${name}`);
const end = migration.indexOf(nextMarker, start);
assert.notEqual(start, -1, `missing migration function ${name}`);
assert.notEqual(end, -1, `missing boundary after migration function ${name}`);
return migration.slice(start, end);
}
test("focus/receipt consistency migration is forward-only and business-tree only", () => {
assert.ok(migrationFilename > "20260814060000_product_feature_flags.sql");
assert.match(migration, /^-- Rectification focus RPC[\s\S]*\nbegin;[\s\S]*^commit;$/m);
assert.equal(existsSync(migrationCopy), false);
});
test("SQL-shaped focus contract returns the same complete row on create and idempotent replay", () => {
const focusFunction = migrationFunction(
"set_agentic_rectification_conversation_focus",
"revoke all on function public.set_agentic_rectification_conversation_focus",
);
assert.equal((focusFunction.match(/'focus', to_jsonb\(v_focus\)/g) ?? []).length, 2);
assert.match(focusFunction, /'focus', to_jsonb\(v_focus\),\s*'idempotent', true/);
assert.match(focusFunction, /'focus', to_jsonb\(v_focus\),\s*'idempotent', false/);
assert.doesNotMatch(focusFunction, /'focus_id'/);
});
test("tool service accepts the identical SQL focus envelope for create and replay", async () => {
let callCount = 0;
const focus = activeFocusFixture({
targetEvidenceId: EVIDENCE_ID,
expectedAnswerSchema: { required: ["month"] },
});
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
callCount += 1;
return { focus, idempotent: callCount === 2 };
},
});
const input = {
questionId: "career-month-question",
intent: "clarify_event_date",
targetEvidenceId: EVIDENCE_ID,
targetDomain: "career",
targetKind: "career_entry",
expectedAnswerSchema: { required: ["month"] },
} as const;
const created = await setV10ConversationFocus(accounting.client, USER_ID, CASE_ID, input);
const replayed = await setV10ConversationFocus(accounting.client, USER_ID, CASE_ID, input);
assert.deepEqual(replayed.focus, created.focus);
assert.equal(created.focus.id, FOCUS_ID);
assert.equal(created.idempotent, false);
assert.equal(replayed.idempotent, true);
});
test("persisted receipt selects the latest attempt and exposes latest terminal tool activity", async () => {
const receiptFunction = migrationFunction(
"get_agentic_rectification_turn_receipt",
"revoke all on function public.get_agentic_rectification_turn_receipt",
);
assert.match(receiptFunction, /where turn_id = p_turn_id\s+order by attempt_number desc\s+limit 1/);
assert.match(receiptFunction, /tr\.status in \('completed', 'failed'\)/);
assert.match(receiptFunction, /select distinct on \(tr\.tool_name\)/);
assert.match(receiptFunction, /'tool_activities', v_tool_activities/);
assert.doesNotMatch(receiptFunction, /where tr\.turn_id = p_turn_id and tr\.status = 'completed'/);
const accounting = fakeAccounting({
get_agentic_rectification_turn_receipt: () => ({
turn_id: TURN_ID,
attempt_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
status: "retryable",
skill_name: "jyotish-birth-time-rectification",
skill_version: "9.0.0",
engine_version: null,
phases: [{ phase: "run.failed", tool: null }],
tool_activities: [
{ tool: "rectification-read-case", status: "completed", methods: [] },
{ tool: "rectification-set-focus", status: "failed", methods: ["d1-rashi"] },
],
tools: ["rectification-read-case"],
methods: [],
started_at: "2026-08-15T01:00:00.000Z",
completed_at: null,
}),
});
const receipt = await loadV9TurnReceipt(accounting.client, USER_ID, CASE_ID, TURN_ID);
assert.ok(receipt);
assert.deepEqual(receipt.toolActivities, [
{ tool: "rectification-read-case", status: "completed", methods: [] },
{ tool: "rectification-set-focus", status: "failed", methods: [] },
]);
const projectActivities = (activities: typeof receipt.toolActivities) => {
let state = createRectificationActivityReceiptState();
for (const activity of activities) {
state = reduceRectificationActivityReceipt(state, activity);
}
return receiptFromRectificationActivityState(state);
};
const persistedView = projectActivities(receipt.toolActivities);
const liveView = projectActivities([
{ tool: "rectification-read-case", status: "completed", methods: [] },
{ tool: "rectification-set-focus", status: "failed", methods: [] },
]);
assert.deepEqual(persistedView, liveView);
assert.deepEqual(persistedView, {
steps: ["rectification-read-case"],
methods: [],
failedTool: "rectification-set-focus",
});
});
const caseRoute = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url),
"utf8",
);
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
test("refresh projection rebuilds the same activity receipt from persisted terminal states", () => {
assert.match(caseRoute, /tool_activities: receipt\.toolActivities\.map/);
assert.match(chat, /Array\.isArray\(receipt\.tool_activities\)/);
assert.match(chat, /reduceRectificationActivityReceipt\(state/);
assert.match(chat, /receiptFromRectificationActivityState\(state\)/);
});
@@ -48,7 +48,7 @@ const completeProfile = {
birth_date: "1997-08-08", birth_date: "1997-08-08",
birth_place_label: "河北省邯郸市", birth_place_label: "河北省邯郸市",
reported_birth_time: "05:00", reported_birth_time: "05:00",
active_birth_time: null, active_birth_time: "05:08",
birth_time_source: "family_exact", birth_time_source: "family_exact",
birth_time_period: null, birth_time_period: null,
uncertainty_before_minutes: 10, uncertainty_before_minutes: 10,
@@ -67,15 +67,19 @@ function openRpc(data: unknown): RpcHandler {
return async () => ({ data, error: null }); return async () => ({ data, error: null });
} }
test("loadV9RectificationProfile derives baseline snapshot, fingerprint and range server-side", async () => { test("loadV9RectificationProfile uses a server-owned radius around reported time", async () => {
const accounting = fakeAccounting({ profile: completeProfile }); const accounting = fakeAccounting({ profile: completeProfile });
const profile = await loadV9RectificationProfile(accounting, "user-1"); const profile = await loadV9RectificationProfile(accounting, "user-1");
assert.equal(profile.userId, "user-1"); assert.equal(profile.userId, "user-1");
assert.equal(profile.baseline.birth_date, "1997-08-08"); assert.equal(profile.baseline.birth_date, "1997-08-08");
assert.equal(profile.baseline.birth_place_label, "河北省邯郸市"); assert.equal(profile.baseline.birth_place_label, "河北省邯郸市");
assert.equal(profile.baseline.timezone_id, "Asia/Shanghai"); assert.equal(profile.baseline.timezone_id, "Asia/Shanghai");
assert.equal(profile.baseline.reported_birth_time, "05:00");
assert.equal(profile.baseline.active_birth_time, null);
assert.equal(profile.baselineFingerprint.length, 64); assert.equal(profile.baselineFingerprint.length, 64);
assert.deepEqual(profile.candidateRange, { start_time: "04:50", end_time: "05:10" }); assert.equal(profile.baseline.uncertainty_before_minutes, 10);
assert.equal(profile.baseline.uncertainty_after_minutes, 10);
assert.deepEqual(profile.candidateRange, { start_time: "04:45", end_time: "05:15" });
assert.ok(!("password" in profile.baseline)); assert.ok(!("password" in profile.baseline));
}); });
@@ -89,6 +93,21 @@ test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async
assert.equal(profile.baseline.birth_date, "1997-08-08"); assert.equal(profile.baseline.birth_date, "1997-08-08");
}); });
test("family exact 0/0 remains profile truth while the Case keeps a movable search window", async () => {
const accounting = fakeAccounting({
profile: {
...completeProfile,
uncertainty_before_minutes: 0,
uncertainty_after_minutes: 0,
},
});
const profile = await loadV9RectificationProfile(accounting, "user-1");
assert.equal(profile.baseline.uncertainty_before_minutes, 0);
assert.equal(profile.baseline.uncertainty_after_minutes, 0);
assert.deepEqual(profile.candidateRange, { start_time: "04:45", end_time: "05:15" });
});
test("loadV9RectificationProfile rejects incomplete profiles without creating a case", async () => { test("loadV9RectificationProfile rejects incomplete profiles without creating a case", async () => {
for (const incomplete of [ for (const incomplete of [
{ ...completeProfile, latitude: null, longitude: null, timezone_offset: null }, { ...completeProfile, latitude: null, longitude: null, timezone_offset: null },
@@ -104,48 +123,80 @@ test("loadV9RectificationProfile rejects incomplete profiles without creating a
} }
}); });
test("period-only profiles derive a period range; unknown derives the full day", async () => { test("homepage and new fail closed when a fresh profile has no reported time", async () => {
const period = fakeAccounting({ const cases = [
profile: { { intent: "homepage", source: "period_only", period: "morning" },
...completeProfile, { intent: "new", source: "unknown", period: null },
reported_birth_time: null, { intent: "homepage", source: "legacy_import", period: "evening" },
birth_time_source: "period_only", ] as const;
birth_time_period: "morning",
},
});
const periodProfile = await loadV9RectificationProfile(period, "user-1");
assert.deepEqual(periodProfile.candidateRange, { start_time: "08:00", end_time: "11:59" });
const unknown = fakeAccounting({ for (const item of cases) {
profile: { let rpcCalled = false;
...completeProfile, const accounting = fakeAccounting({
reported_birth_time: null, profile: {
birth_time_source: "unknown", ...completeProfile,
birth_time_period: null, reported_birth_time: null,
}, birth_time_source: item.source,
}); birth_time_period: item.period,
const unknownProfile = await loadV9RectificationProfile(unknown, "user-1"); },
assert.deepEqual(unknownProfile.candidateRange, { start_time: "00:00", end_time: "23:59" }); rpc: async () => {
rpcCalled = true;
return { data: null, error: null };
},
});
await assert.rejects(
() => openRectificationCase(accounting, "user-1", { intent: item.intent, requestId }),
(error: unknown) =>
error instanceof RectificationCaseServiceError && error.code === "profile_incomplete",
);
assert.equal(rpcCalled, false);
}
}); });
test("homepage open with no history creates one case and one session", async () => { test("homepage and new opens ignore active time and legacy uncertainty for their fresh range", async () => {
const accounting = fakeAccounting({ for (const intent of ["homepage", "new"] as const) {
profile: completeProfile, const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
rpc: openRpc({ const accounting = fakeAccounting({
disposition: "created", profile: completeProfile,
case_id: caseId, rpc: async (_fn, args) => {
session_id: sessionId, capturedArgs.value = args;
status: "draft", return {
should_start_opening: true, data: {
skill_version: "9.0.0", disposition: "created",
}), case_id: caseId,
}); session_id: sessionId,
const request: OpenRectificationCaseRequest = { intent: "homepage", requestId }; status: "draft",
const response = await openRectificationCase(accounting, "user-1", request); should_start_opening: true,
assert.equal(response.disposition, "created"); skill_version: "9.0.0",
assert.equal(response.caseId, caseId); },
assert.equal(response.sessionId, sessionId); error: null,
assert.equal(response.shouldStartOpening, true); };
},
});
const request: OpenRectificationCaseRequest = { intent, requestId };
const response = await openRectificationCase(accounting, "user-1", request);
assert.equal(response.disposition, "created");
assert.equal(response.caseId, caseId);
assert.equal(response.sessionId, sessionId);
assert.equal(response.shouldStartOpening, true);
assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "04:45", end_time: "05:15" });
assert.deepEqual(capturedArgs.value?.p_baseline_birth_snapshot, {
birth_date: "1997-08-08",
birth_place_label: "河北省邯郸市",
latitude: 36.420487,
longitude: 114.209936,
timezone_id: "Asia/Shanghai",
timezone_offset: 8,
birth_time_source: "family_exact",
birth_time_period: null,
reported_birth_time: "05:00",
active_birth_time: null,
uncertainty_before_minutes: 10,
uncertainty_after_minutes: 10,
});
}
}); });
test("homepage open may create a new case even when resumable history exists", async () => { test("homepage open may create a new case even when resumable history exists", async () => {
@@ -20,6 +20,11 @@ import {
} from "./rectification-v9-test-support.ts"; } from "./rectification-v9-test-support.ts";
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { safeToolErrorCode } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
} from "../src/lib/rectification-activity-receipt.ts";
type StreamChunk = { type StreamChunk = {
type: string; type: string;
@@ -748,6 +753,139 @@ for (const failureCode of ["stream_aborted", "stream_unfinished", "empty_stream"
}); });
} }
test("a failed set-focus cannot complete a question turn even when the agent emits answer text", async () => {
let buildCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: (_fn, args) => ({
turn_id: TURN_ID,
status: args.p_status,
idempotent: false,
}),
});
const failedFocusAttempt = () => attemptStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("invalid_focus") }),
chunk("text-delta", { text: "主问题:请确认这段经历发生在哪个月?" }),
chunk("finish"),
], { inputTokens: 41, outputTokens: 23 });
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => {
buildCount += 1;
return failedFocusAttempt() as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 2);
assert.equal(result.ok, false);
assert.equal(result.turnStatus, "retryable");
assert.equal(result.errorCode, "focus_persistence_failed");
assert.equal(result.answerText, "");
assert.deepEqual(result.toolsUsed, ["rectification-read-case", "rectification-set-focus"]);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.some((event) => event.type === "answer.delta"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.equal(
emitted.some((event) => event.type === "tool.activity"
&& (event as { tool?: string; status?: string }).tool === "rectification-set-focus"
&& (event as { tool?: string; status?: string }).status === "failed"),
true,
);
assert.equal(emitted.at(-1)?.type, "run.failed");
const attemptFinalizations = accounting.calls
.filter((call) => call.fn === "finalize_agentic_rectification_run_attempt")
.map((call) => ({
status: call.args.p_status,
errorCode: call.args.p_error_code,
usage: call.args.p_usage,
}));
assert.deepEqual(attemptFinalizations, [
{ status: "retryable", errorCode: "focus_persistence_failed", usage: { inputTokens: 0, outputTokens: 0 } },
{ status: "retryable", errorCode: "focus_persistence_failed", usage: { inputTokens: 0, outputTokens: 0 } },
]);
assert.equal(
accounting.calls.some((call) => call.fn === "insert_agentic_rectification_run_phase"
&& call.args.p_phase === "run.completed"),
false,
);
const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.deepEqual(finalizedTurn?.args, {
p_user_id: USER_ID,
p_case_id: CASE_ID,
p_turn_id: TURN_ID,
p_attempt_id: SECOND_ATTEMPT_ID,
p_status: "retryable",
p_assistant_message: null,
p_successful_attempt_id: null,
});
});
test("a same-attempt set-focus retry that finishes completed can commit the question turn", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => attemptStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-error", { toolName: "rectification-set-focus", error: new Error("invalid_focus") }),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: "主问题:请确认这段经历发生在哪个月?" }),
chunk("finish"),
], { inputTokens: 43, outputTokens: 29 }) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.turnStatus, "completed");
assert.equal(result.errorCode, null);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "主问题:请确认这段经历发生在哪个月?" }],
);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
let receiptState = createRectificationActivityReceiptState();
const focusStatuses: string[] = [];
for (const event of emitted) {
const activity = event as { type: string; tool?: string; status?: string };
if (activity.type !== "tool.activity" || activity.tool !== "rectification-set-focus") continue;
if (activity.status !== "started" && activity.status !== "completed" && activity.status !== "failed") continue;
focusStatuses.push(activity.status);
receiptState = reduceRectificationActivityReceipt(receiptState, {
tool: "rectification-set-focus",
status: activity.status,
});
}
assert.deepEqual(focusStatuses, ["started", "failed", "started", "completed"]);
assert.deepEqual(receiptFromRectificationActivityState(receiptState), {
steps: ["rectification-set-focus"],
methods: [],
});
});
test("an unclaimed V10 attempt never starts the model", async () => { test("an unclaimed V10 attempt never starts the model", async () => {
let buildCount = 0; let buildCount = 0;
const accounting = fakeAccounting({ const accounting = fakeAccounting({