fix(rectification): reset fresh cases to reported time
This commit is contained in:
@@ -3273,3 +3273,33 @@
|
||||
- 防复发:任何 revision state 快路径都必须验证可读性,并保留容器镜像 revision 的只读回退,不得仅以路径存在作为可消费条件。
|
||||
- 相关记录:BUG-083、BUG-192
|
||||
- 修复版本:本次 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,
|
||||
status: receiptStatusFromTurn(receipt.status),
|
||||
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,
|
||||
methods: receipt.methods,
|
||||
started_at: receipt.startedAt,
|
||||
|
||||
@@ -6,11 +6,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
|
||||
import {
|
||||
birthTimeDisplayState,
|
||||
birthTimePeriodOptions,
|
||||
birthTimeSourceDefaults,
|
||||
birthTimeSourceOptions,
|
||||
type BirthTimeDraft,
|
||||
type BirthTimeDraftPatch,
|
||||
type BirthTimeSource,
|
||||
} from "@/lib/birth-time-intake-model";
|
||||
|
||||
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) {
|
||||
const groupId = useId();
|
||||
const source = value.birthTimeSource;
|
||||
const isConfirmed = value.birthTimeStatus === "confirmed";
|
||||
const displayState = birthTimeDisplayState(value);
|
||||
const knowledgeMode = source === "period_only" || source === "unknown"
|
||||
? "uncertain"
|
||||
: source ? "exact" : "";
|
||||
const usesClockTime = source === "hospital_record"
|
||||
|| source === "family_exact"
|
||||
|| source === "approximate"
|
||||
@@ -151,20 +114,16 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
)}
|
||||
|
||||
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
|
||||
<legend>你对出生时间了解多少?</legend>
|
||||
<p className="birth-time-source-intro">不用猜具体分钟,先选一个最符合你的情况的选项。</p>
|
||||
<legend>填写出生时间</legend>
|
||||
<p className="birth-time-source-intro">请填写你初始化提供的具体出生时间。</p>
|
||||
<div className="birth-time-source-list">
|
||||
{birthTimeSourceOptions.map((option) => (
|
||||
<label
|
||||
className={`birth-time-source-option ${option.value === "family_exact"
|
||||
? knowledgeMode === "exact" ? "is-selected" : ""
|
||||
: knowledgeMode === "uncertain" ? "is-selected" : ""}`}
|
||||
className={`birth-time-source-option ${source === option.value ? "is-selected" : ""}`}
|
||||
key={option.value}
|
||||
>
|
||||
<input
|
||||
checked={option.value === "family_exact"
|
||||
? knowledgeMode === "exact"
|
||||
: knowledgeMode === "uncertain"}
|
||||
checked={source === option.value}
|
||||
name={`birth-time-source-${groupId}`}
|
||||
type="radio"
|
||||
value={option.value}
|
||||
@@ -172,7 +131,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
birthTimeSource: option.value,
|
||||
birthTimeStatus: "reported",
|
||||
time: "",
|
||||
...sourceDefaults[option.value],
|
||||
...birthTimeSourceDefaults[option.value],
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
@@ -197,91 +156,6 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
{source === "hospital_record" && (
|
||||
<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>
|
||||
|
||||
@@ -36,6 +36,11 @@ type PersistedTurn = Readonly<{
|
||||
receipt?: Readonly<{
|
||||
status: string;
|
||||
phases: readonly string[];
|
||||
tool_activities?: readonly Readonly<{
|
||||
tool: string;
|
||||
status: string;
|
||||
methods?: readonly string[];
|
||||
}>[];
|
||||
tools: readonly string[];
|
||||
methods?: readonly string[];
|
||||
skill_name?: string;
|
||||
@@ -94,6 +99,21 @@ const ACTIVE_TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
|
||||
|
||||
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
|
||||
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 {
|
||||
steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))],
|
||||
methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))],
|
||||
|
||||
@@ -121,8 +121,8 @@ export const accountProfilePatchSchema = z.object({
|
||||
ensureNoPeriod();
|
||||
} else if (source === "family_exact") {
|
||||
if (!time) addIssue("reported_birth_time", "家人记忆需要具体时间");
|
||||
if (![5, 10, 15].includes(before ?? -1) || before !== after) {
|
||||
addIssue("uncertainty_before_minutes", "家人记忆误差必须为前后 5、10 或 15 分钟");
|
||||
if (![0, 5, 10, 15].includes(before ?? -1) || before !== after) {
|
||||
addIssue("uncertainty_before_minutes", "准确时间填报必须前后一致");
|
||||
}
|
||||
ensureNoPeriod();
|
||||
} else if (source === "approximate") {
|
||||
|
||||
@@ -83,10 +83,42 @@ export function formatBirthDate(value: Date): string {
|
||||
}
|
||||
|
||||
export const birthTimeSourceOptions = [
|
||||
{ value: "family_exact", label: "我知道准确出生时间", hint: "填写后直接用于当前分析,无需先做生时校正" },
|
||||
{ value: "period_only", label: "我不确定准确时间", hint: "告诉我们大致时段;完全不清楚也可以直接跳过" },
|
||||
{ value: "family_exact", label: "我知道准确出生时间", hint: "保存为初始化填报时间,不会自动标记为引擎确认" },
|
||||
] 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 = [
|
||||
{ value: "early_morning", label: "凌晨 / 清晨(04:00—07:59)" },
|
||||
{ value: "morning", label: "上午(08:00—11:59)" },
|
||||
@@ -163,12 +195,14 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
|
||||
return isBirthClockTime(draft.reportedTime || draft.time);
|
||||
case "family_exact":
|
||||
return isBirthClockTime(draft.reportedTime)
|
||||
&& [5, 10, 15].includes(draft.uncertaintyBeforeMinutes ?? -1)
|
||||
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
|
||||
&& draft.uncertaintyBeforeMinutes === 0
|
||||
&& draft.uncertaintyAfterMinutes === 0;
|
||||
case "approximate":
|
||||
return isBirthClockTime(draft.reportedTime)
|
||||
&& [15, 30, 60].includes(draft.uncertaintyBeforeMinutes ?? -1)
|
||||
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
|
||||
&& (draft.uncertaintyBeforeMinutes === null
|
||||
|| [15, 30, 60].includes(draft.uncertaintyBeforeMinutes))
|
||||
&& (draft.uncertaintyAfterMinutes === null
|
||||
|| draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes);
|
||||
case "period_only":
|
||||
return birthTimePeriodOptions.some((option) => option.value === draft.birthTimePeriod)
|
||||
&& !draft.reportedTime
|
||||
@@ -267,9 +301,11 @@ export function birthTimePersistenceValues(draft: BirthTimeDraft) {
|
||||
: null;
|
||||
const uncertainty = draft.birthTimeSource === "hospital_record"
|
||||
? 2
|
||||
: draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate"
|
||||
? draft.uncertaintyBeforeMinutes
|
||||
: null;
|
||||
: draft.birthTimeSource === "family_exact"
|
||||
? 0
|
||||
: draft.birthTimeSource === "approximate"
|
||||
? draft.uncertaintyBeforeMinutes ?? birthTimeSourceDefaults.approximate.uncertaintyBeforeMinutes
|
||||
: null;
|
||||
return {
|
||||
reported_birth_time: reportedTime,
|
||||
birth_time_source: draft.birthTimeSource || null,
|
||||
@@ -289,7 +325,7 @@ export function describeBirthTimeDraft(draft: BirthTimeDraft) {
|
||||
case "family_exact":
|
||||
return `${date} ${draft.reportedTime}(填报准确时间)`;
|
||||
case "approximate":
|
||||
return `${date},约 ${draft.reportedTime}(前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
|
||||
return `${date},约 ${draft.reportedTime}`;
|
||||
case "period_only":
|
||||
return `${date},${periodLabels[draft.birthTimePeriod]}${draft.birthTimeClue.trim() ? `(${draft.birthTimeClue.trim()})` : ""}`;
|
||||
case "unknown":
|
||||
|
||||
@@ -14,7 +14,7 @@ type ToolTerminalStatus = "completed" | "failed";
|
||||
|
||||
export type RectificationActivityReceiptState = Readonly<{
|
||||
completedSteps: readonly PublicRectificationTool[];
|
||||
methods: readonly PublicRectificationMethod[];
|
||||
methodsByTool: Readonly<Partial<Record<PublicRectificationTool, readonly PublicRectificationMethod[]>>>;
|
||||
terminalStatus: Readonly<Partial<Record<PublicRectificationTool, ToolTerminalStatus>>>;
|
||||
failureOrder: readonly PublicRectificationTool[];
|
||||
}>;
|
||||
@@ -28,7 +28,7 @@ type ReceiptActivityEvent = Readonly<{
|
||||
export function createRectificationActivityReceiptState(): RectificationActivityReceiptState {
|
||||
return {
|
||||
completedSteps: [],
|
||||
methods: [],
|
||||
methodsByTool: {},
|
||||
terminalStatus: {},
|
||||
failureOrder: [],
|
||||
};
|
||||
@@ -46,7 +46,10 @@ export function reduceRectificationActivityReceipt(
|
||||
completedSteps: state.completedSteps.includes(event.tool)
|
||||
? state.completedSteps
|
||||
: [...state.completedSteps, event.tool],
|
||||
methods: [...new Set([...state.methods, ...(event.methods ?? [])])],
|
||||
methodsByTool: {
|
||||
...state.methodsByTool,
|
||||
[event.tool]: [...new Set(event.methods ?? [])],
|
||||
},
|
||||
terminalStatus,
|
||||
failureOrder: state.failureOrder.filter((tool) => tool !== event.tool),
|
||||
};
|
||||
@@ -54,6 +57,8 @@ export function reduceRectificationActivityReceipt(
|
||||
|
||||
return {
|
||||
...state,
|
||||
completedSteps: state.completedSteps.filter((tool) => tool !== event.tool),
|
||||
methodsByTool: { ...state.methodsByTool, [event.tool]: [] },
|
||||
terminalStatus,
|
||||
failureOrder: [
|
||||
...state.failureOrder.filter((tool) => tool !== event.tool),
|
||||
@@ -70,7 +75,7 @@ export function receiptFromRectificationActivityState(
|
||||
.find((tool) => state.terminalStatus[tool] === "failed");
|
||||
return {
|
||||
steps: [...state.completedSteps],
|
||||
methods: [...state.methods],
|
||||
methods: [...new Set(state.completedSteps.flatMap((tool) => state.methodsByTool[tool] ?? []))],
|
||||
...(failedTool ? { failedTool } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ const RETRYABLE_ERROR_CODES = new Set([
|
||||
"skill_not_loaded",
|
||||
"skill_not_bound",
|
||||
"case_not_loaded",
|
||||
"focus_persistence_failed",
|
||||
]);
|
||||
|
||||
function first(value: unknown): unknown {
|
||||
@@ -126,6 +127,7 @@ function safeErrorCode(error: unknown): string {
|
||||
"skill_not_bound",
|
||||
"case_not_loaded",
|
||||
"repeated_tool_call",
|
||||
"focus_persistence_failed",
|
||||
]) {
|
||||
if (message.includes(code)) return code;
|
||||
}
|
||||
@@ -327,6 +329,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (!outcome.ok) {
|
||||
await billing.release();
|
||||
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
|
||||
for (const event of outcome.events) await emit(event);
|
||||
await emit({ type: "run.failed" });
|
||||
return {
|
||||
ok: false,
|
||||
@@ -334,8 +337,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
turnStatus: outcome.status,
|
||||
skillLoaded: false,
|
||||
answerText: "",
|
||||
phases: [],
|
||||
toolsUsed: [],
|
||||
phases: outcome.phases,
|
||||
toolsUsed: outcome.toolsUsed,
|
||||
errorCode: outcome.errorCode,
|
||||
};
|
||||
}
|
||||
@@ -439,6 +442,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
const phases: string[] = [];
|
||||
const toolsUsed = new Set<string>();
|
||||
const events: PublicStreamEvent[] = [];
|
||||
const toolTerminalStatus = new Map<string, "completed" | "failed">();
|
||||
const emittedKeys = new Set<string>();
|
||||
const repeatedCalls = new Map<string, number>();
|
||||
let phaseSequence = 0;
|
||||
@@ -491,7 +495,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
}
|
||||
|
||||
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);
|
||||
if (phaseEvent) {
|
||||
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 (!finished) return failedAttempt(attemptId, "stream_unfinished");
|
||||
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 }));
|
||||
await recordPhase("answer.composed");
|
||||
|
||||
@@ -102,54 +102,22 @@ function numberOrNull(value: unknown): number | 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 {
|
||||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||||
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")}`;
|
||||
}
|
||||
|
||||
function fallbackUncertainty(source: string): number {
|
||||
if (source === "hospital_record" || source === "hospital") return 2;
|
||||
if (source === "family_exact" || source === "family_clear") return 15;
|
||||
if (source === "approximate" || source === "family_vague") return 60;
|
||||
return 2;
|
||||
}
|
||||
// This is an engine execution boundary, not a user-declared uncertainty. Fresh
|
||||
// Cases always start from reported_birth_time with enough room to rectify.
|
||||
const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15;
|
||||
|
||||
function deriveCandidateRange(input: {
|
||||
activeTime: string | null;
|
||||
reportedTime: string | null;
|
||||
source: string;
|
||||
period: string | null;
|
||||
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 deriveCandidateRange(reportedTime: string | null): { start_time: string; end_time: string } {
|
||||
if (!reportedTime) throw new RectificationCaseServiceError("profile_incomplete");
|
||||
return {
|
||||
start_time: shiftedTime(reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES),
|
||||
end_time: shiftedTime(reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES),
|
||||
};
|
||||
}
|
||||
|
||||
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
|
||||
@@ -177,7 +145,7 @@ export async function loadV9RectificationProfile(
|
||||
const { data, error } = await accounting
|
||||
.from("profiles")
|
||||
.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)
|
||||
.single();
|
||||
@@ -192,7 +160,6 @@ export async function loadV9RectificationProfile(
|
||||
const timezoneOffset = numberOrNull(row.timezone_offset);
|
||||
const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : "";
|
||||
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 uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes);
|
||||
const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes);
|
||||
@@ -210,7 +177,8 @@ export async function loadV9RectificationProfile(
|
||||
birth_time_source: source,
|
||||
birth_time_period: period,
|
||||
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_after_minutes: uncertaintyAfter,
|
||||
};
|
||||
@@ -219,14 +187,7 @@ export async function loadV9RectificationProfile(
|
||||
userId,
|
||||
baseline,
|
||||
baselineFingerprint: baselineFingerprint(baseline),
|
||||
candidateRange: deriveCandidateRange({
|
||||
activeTime,
|
||||
reportedTime,
|
||||
source,
|
||||
period,
|
||||
uncertaintyBefore,
|
||||
uncertaintyAfter,
|
||||
}),
|
||||
candidateRange: deriveCandidateRange(reportedTime),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -820,6 +820,11 @@ export type V9TurnReceipt = Readonly<{
|
||||
skillVersion: string;
|
||||
engineVersion: string | null;
|
||||
phases: readonly Readonly<{ phase: string; tool: string | null }>[];
|
||||
toolActivities: readonly Readonly<{
|
||||
tool: PublicRectificationTool;
|
||||
status: "completed" | "failed";
|
||||
methods: readonly PublicRectificationMethod[];
|
||||
}>[];
|
||||
tools: readonly string[];
|
||||
methods: readonly PublicRectificationMethod[];
|
||||
startedAt: string;
|
||||
@@ -843,6 +848,21 @@ export async function loadV9TurnReceipt(
|
||||
const phaseRow = item as Record<string, unknown>;
|
||||
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 {
|
||||
turnId: row.turn_id,
|
||||
attemptId: rowText(row.attempt_id),
|
||||
@@ -851,6 +871,7 @@ export async function loadV9TurnReceipt(
|
||||
skillVersion: String(row.skill_version ?? ""),
|
||||
engineVersion: rowText(row.engine_version),
|
||||
phases,
|
||||
toolActivities,
|
||||
tools: rowArray(row.tools).map((item) => String(item)),
|
||||
methods: rowArray(row.methods).filter(isPublicRectificationMethod),
|
||||
startedAt: String(row.started_at ?? ""),
|
||||
|
||||
+235
@@ -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;
|
||||
@@ -57,8 +57,8 @@ test("profile patch schema validates calendar, clock, source requirements, and l
|
||||
birth_time_source: "family_exact",
|
||||
birth_time_period: null,
|
||||
birth_time_clue: null,
|
||||
uncertainty_before_minutes: 10,
|
||||
uncertainty_after_minutes: 10,
|
||||
uncertainty_before_minutes: 0,
|
||||
uncertainty_after_minutes: 0,
|
||||
country_code: "CN",
|
||||
province_code: "130000",
|
||||
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, 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, 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({ reported_birth_time: "05:30" }).success, false);
|
||||
assert.equal(accountProfilePatchSchema.safeParse({ ...valid, longitude: null }).success, false);
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
assistantIntentCopy,
|
||||
birthTimeDisplayState,
|
||||
birthTimePersistenceValues,
|
||||
birthTimeSourceDefaults,
|
||||
birthTimeSourceOptions,
|
||||
describeBirthTimeDraft,
|
||||
formatBirthDate,
|
||||
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(period), true);
|
||||
assert.equal(isBirthTimeDraftReady(incompleteApproximate), false);
|
||||
assert.equal(isBirthTimeDraftReady(incompleteApproximate), 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", () => {
|
||||
const candidate = {
|
||||
...emptyDraft,
|
||||
@@ -71,8 +102,8 @@ test("declared birth data completes onboarding without an active or confirmed mi
|
||||
...emptyDraft,
|
||||
reportedTime: "05:30",
|
||||
birthTimeSource: "family_exact",
|
||||
uncertaintyBeforeMinutes: 10,
|
||||
uncertaintyAfterMinutes: 10,
|
||||
uncertaintyBeforeMinutes: 0,
|
||||
uncertaintyAfterMinutes: 0,
|
||||
birthTimeStatus: "reported",
|
||||
} satisfies BirthTimeDraft;
|
||||
const declaredPeriod = {
|
||||
@@ -93,8 +124,8 @@ test("declared completeness validates the actual calendar date, clock, source fi
|
||||
...emptyDraft,
|
||||
birthTimeSource: "family_exact",
|
||||
reportedTime: "05:30",
|
||||
uncertaintyBeforeMinutes: 10,
|
||||
uncertaintyAfterMinutes: 10,
|
||||
uncertaintyBeforeMinutes: 0,
|
||||
uncertaintyAfterMinutes: 0,
|
||||
} satisfies BirthTimeDraft;
|
||||
|
||||
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,
|
||||
} satisfies BirthTimeDraft;
|
||||
|
||||
assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30(前后 30 分钟)");
|
||||
assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30");
|
||||
assert.equal(
|
||||
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"), "");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
assert.deepEqual(birthTimeSourceOptions.map((option) => option.value), ["family_exact"]);
|
||||
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\)/);
|
||||
});
|
||||
|
||||
@@ -59,12 +59,36 @@ test("a later failure overrides an earlier completion for the same tool", () =>
|
||||
});
|
||||
|
||||
assert.deepEqual(receiptFromRectificationActivityState(state), {
|
||||
steps: ["rectification-read-case"],
|
||||
steps: [],
|
||||
methods: [],
|
||||
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(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"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_place_label: "河北省邯郸市",
|
||||
reported_birth_time: "05:00",
|
||||
active_birth_time: null,
|
||||
active_birth_time: "05:08",
|
||||
birth_time_source: "family_exact",
|
||||
birth_time_period: null,
|
||||
uncertainty_before_minutes: 10,
|
||||
@@ -67,15 +67,19 @@ function openRpc(data: unknown): RpcHandler {
|
||||
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 profile = await loadV9RectificationProfile(accounting, "user-1");
|
||||
assert.equal(profile.userId, "user-1");
|
||||
assert.equal(profile.baseline.birth_date, "1997-08-08");
|
||||
assert.equal(profile.baseline.birth_place_label, "河北省邯郸市");
|
||||
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.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));
|
||||
});
|
||||
|
||||
@@ -89,6 +93,21 @@ test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async
|
||||
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 () => {
|
||||
for (const incomplete of [
|
||||
{ ...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 () => {
|
||||
const period = fakeAccounting({
|
||||
profile: {
|
||||
...completeProfile,
|
||||
reported_birth_time: null,
|
||||
birth_time_source: "period_only",
|
||||
birth_time_period: "morning",
|
||||
},
|
||||
});
|
||||
const periodProfile = await loadV9RectificationProfile(period, "user-1");
|
||||
assert.deepEqual(periodProfile.candidateRange, { start_time: "08:00", end_time: "11:59" });
|
||||
test("homepage and new fail closed when a fresh profile has no reported time", async () => {
|
||||
const cases = [
|
||||
{ intent: "homepage", source: "period_only", period: "morning" },
|
||||
{ intent: "new", source: "unknown", period: null },
|
||||
{ intent: "homepage", source: "legacy_import", period: "evening" },
|
||||
] as const;
|
||||
|
||||
const unknown = fakeAccounting({
|
||||
profile: {
|
||||
...completeProfile,
|
||||
reported_birth_time: null,
|
||||
birth_time_source: "unknown",
|
||||
birth_time_period: null,
|
||||
},
|
||||
});
|
||||
const unknownProfile = await loadV9RectificationProfile(unknown, "user-1");
|
||||
assert.deepEqual(unknownProfile.candidateRange, { start_time: "00:00", end_time: "23:59" });
|
||||
for (const item of cases) {
|
||||
let rpcCalled = false;
|
||||
const accounting = fakeAccounting({
|
||||
profile: {
|
||||
...completeProfile,
|
||||
reported_birth_time: null,
|
||||
birth_time_source: item.source,
|
||||
birth_time_period: item.period,
|
||||
},
|
||||
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 () => {
|
||||
const accounting = fakeAccounting({
|
||||
profile: completeProfile,
|
||||
rpc: openRpc({
|
||||
disposition: "created",
|
||||
case_id: caseId,
|
||||
session_id: sessionId,
|
||||
status: "draft",
|
||||
should_start_opening: true,
|
||||
skill_version: "9.0.0",
|
||||
}),
|
||||
});
|
||||
const request: OpenRectificationCaseRequest = { intent: "homepage", 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);
|
||||
test("homepage and new opens ignore active time and legacy uncertainty for their fresh range", async () => {
|
||||
for (const intent of ["homepage", "new"] as const) {
|
||||
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
|
||||
const accounting = fakeAccounting({
|
||||
profile: completeProfile,
|
||||
rpc: async (_fn, args) => {
|
||||
capturedArgs.value = args;
|
||||
return {
|
||||
data: {
|
||||
disposition: "created",
|
||||
case_id: caseId,
|
||||
session_id: sessionId,
|
||||
status: "draft",
|
||||
should_start_opening: true,
|
||||
skill_version: "9.0.0",
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
} from "./rectification-v9-test-support.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 {
|
||||
createRectificationActivityReceiptState,
|
||||
receiptFromRectificationActivityState,
|
||||
reduceRectificationActivityReceipt,
|
||||
} from "../src/lib/rectification-activity-receipt.ts";
|
||||
|
||||
type StreamChunk = {
|
||||
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 () => {
|
||||
let buildCount = 0;
|
||||
const accounting = fakeAccounting({
|
||||
|
||||
Reference in New Issue
Block a user