diff --git a/CHANGELOG.md b/CHANGELOG.md
index f687e2ab..62c43f2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
+## 2026-09-10 — 记下的经历不再因为模型没写正文而整轮失败;范围没变时会说出哪段领先
+
+生时校正补经历后,如果事情已经记下、下一问也算好了,只是模型没写出「记下了」那句,页面不再只剩「没有拿到下一个问题」。会用已经记下的事情写成那句话,下一问照常出现。
+
+点选题如果范围没变,会补一句哪段时间更领先、哪段落后;范围变了仍只说收到或变为哪一段。顶部时间线不再写「还在收窄」:选择题问完时会说明再补带年月的经历才会变,否则写还在核对。Skill 版本不变。
+
+
## 2026-09-10 — 带钟点的经历不再被当成申报出生时段;对照列按全部候选分钟来算
生时校正做到一半时,如果说的是带钟点的经历(比如某日 20:00 左右分手、下午 3 点到 5 点被撞),助手不再误回「搜索范围按资料定、过程中不改」。只有在明确说出生时间,或只报一个钟点范围时,才用这句固定回复。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 4e9a2799..ca24641e 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -9795,3 +9795,35 @@
- 相关记录:BUG-614、BUG-623
- 复发自:BUG-614(对照集合仍被 9 截断)
- 修复版本:待发布
+
+## BUG-633 | 证据轮模型无正文被判整轮失败,下一问已落库却只剩「没有拿到下一个问题」
+
+- 状态:resolved
+- 首次发现:2026-09-10
+- 最近更新:2026-09-10
+- 影响面:`agent-run.ts` `streamAttempt` 收尾、`host-fallback.ts`、`rectification-agentic-chat.tsx` 失败刷新、`rectificationQuestionGapState`
+- 用户现象:补完带年月经历后,助手气泡下面直接是「没有拿到下一个问题。」和「本轮没有生成可展示的回复,状态已记录。」输入框写着「请回答上面的问题…」,上面没有问题。刷新无效。
+- 触发条件:证据轮 `rectification-record-evidence-batch` 已 completed,下一问焦点已落库,模型以 `stop` / `length` / `max_steps` 结束且没有可展示正文。
+- 根因:`empty_stream` 不在可重试集合(`fe87a9ec` 为避免重放证据而移出)。失败路径不结算、不写主持人正文、客户端 `run.failed` 后不刷新快照。问题只挂在 settled 助手消息上,失败轮没有助手行,缺口走 `preparing` 再 `unavailable`。
+- 修复:batch 已完成且无正文时用工具返回的事件复述写成「记下了:…。」,`finalizeTurn("completed")`,phases 含 `answer.host_fallback`,公开 receipt `answer_origin=host_fallback`。`empty_stream` 仍不进 `RETRYABLE_ERROR_CODES`;仅当本 attempt 没有任何公开写工具(batch / set-focus / compare)完成时才 retryable 一次。失败也 `loadCaseSnapshot`;快照有 `current_question` 且 `question_source=focus` 时渲染主持人问题行。
+- 验证:`frontend/tests/rectification-v9-stream.test.ts`、`rectification-host-fallback.test.ts`、`rectification-agentic-entry.test.ts`、`rectification-surface-state.test.ts`、`rectification-spoken-collect.test.ts`。
+- 防复发:有写工具完成不得重试 empty_stream(BUG-186);兜底正文只能来自 batch 返回;问题行 `focus_id` 只能来自 `current_question`。
+- 相关记录:BUG-186、BUG-359、BUG-558、BUG-627
+- 复发自:无
+- 修复版本:待发布
+
+## BUG-634 | 答题旁白只会说「范围没变」,时间线写死「还在收窄」
+
+- 状态:resolved
+- 首次发现:2026-09-10
+- 最近更新:2026-09-10
+- 影响面:`composeChoiceNarration`、`explainScoreMovement`、`RectificationReadonlyRange`
+- 用户现象:多道选择题答完,每次都是「已记录,范围没变。」顶部一直「目前范围 X–Y,还在收窄」,看不出分数有没有动、选择题是不是已经问完。
+- 触发条件:答题后可信区间宽度不变(落后峰值不足淘汰阈值),或带年月区分题已经问完。
+- 根因:旁白只用 `explainRangeChange`,`explainScoreMovement` 算得出领先/落后却没说出来。时间线「还在收窄」是写死的,不看快照里还有没有带年月探针。
+- 修复:范围不变且 deltas 非零时旁白补「04:52–04:53 领先,05:08–05:12 落后」;全零只说「已记录,范围没变。」范围变了仍不讲领先落后(BUG-606)。时间线在 `discriminate_candidates` 且没有带年月探针时写「选择题已问完,再补带年月的经历才会变」,否则「还在核对」,不写「收窄」。
+- 验证:`frontend/tests/rectification-answer-choice.test.ts`、`rectification-surface-state.test.ts`、`agent-voice-copy-contract.test.ts`。
+- 防复发:静态用户文案词表禁止「还在收窄」;聊天源码合同禁止写死该句;范围变了的旁白不得带领先/落后。
+- 相关记录:BUG-593、BUG-606、BUG-629
+- 复发自:无
+- 修复版本:待发布
diff --git a/docs/tasks/PROGRESS-rectification-evidence-turn-empty-answer-20260910.md b/docs/tasks/PROGRESS-rectification-evidence-turn-empty-answer-20260910.md
new file mode 100644
index 00000000..dfe904c7
--- /dev/null
+++ b/docs/tasks/PROGRESS-rectification-evidence-turn-empty-answer-20260910.md
@@ -0,0 +1,40 @@
+# PROGRESS · 证据轮无正文被判失败;答题旁白与时间线无进度(2026-09-10)
+
+工作树:`.worktrees/rectification-evidence-turn-empty-answer-20260910`
+分支:`codex/rectification-evidence-turn-empty-answer-20260910`
+任务书:`docs/tasks/TASK-rectification-evidence-turn-empty-answer-20260910.md`
+基线:任务书写 `719ff55a`;开工接到 `origin/staging` @ `31f2a722`。BUG-626/627、BUG-628 有任务书但无人领取,按任务书串行说明先做本单。
+
+## 开工回执
+
+- 编号 **BUG-633** / **BUG-634**(当时最大已发布号是 632)。
+- 顺序:主持人兜底 → 零写工具才重试 empty_stream → 失败也刷新快照并渲染已落库问题 → 范围不变旁白补领先/落后 → 时间线去掉「还在收窄」→ 记录。
+
+## 已完成
+
+- **BUG-633**:`record-evidence-batch` 已 completed 且无正文时,用 batch 返回的 `accepted_recaps` 写成「记下了:…。」,`answer.host_fallback` + `answer_origin=host_fallback`,正常结算。`empty_stream` 不进 `RETRYABLE_ERROR_CODES`;仅零公开写工具时 retryable 一次。失败路径同样 `loadCaseSnapshot`;`questionPersisted` 渲染主持人问题行。迁移 `20260910010000_rectification_host_fallback_phase.sql` 放行该 phase。每个 attempt 收尾打一条 `RectificationRunDiagnostic` JSON 日志,不落库。
+- **BUG-634**:范围不变且 deltas 非零 →「已记录,范围没变;04:52–04:53 领先,05:08–05:12 落后。」全零 →「已记录,范围没变。」范围变了仍不讲领先落后。时间线:`discriminate_candidates` 且无带年月探针 →「选择题已问完,再补带年月的经历才会变」;否则「还在核对」。
+- 记录:BUG-633/634、CHANGELOG、走查第 8 条、本文件。Skill 版本不变。
+
+## 验收命令(实测)
+
+工作树 `codex/rectification-evidence-turn-empty-answer-20260910`,相对 `origin/staging` @ `31f2a722`。
+
+| 命令 | 结果 |
+| --- | --- |
+| `frontend` `./node_modules/.bin/tsc --noEmit` | **0 error** |
+| `frontend` `npm run lint` | **0 error** / 108 warning(既有) |
+| `frontend` `tsx --test`:`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`(排除 database) | **1102/1102 pass** |
+| `git diff --check` | 干净 |
+
+## 环境缺口
+
+- 无登录态、无 Chrome:`docs/testing/rectification-scenarios-20260907.md` 第 8 节真人走查未做。
+- 未跑完整前端套件 / `next build`。
+- 迁移 `20260910010000_rectification_host_fallback_phase.sql` 待 `Migrate Staging Database` 后部署;未应用时 `answer.host_fallback` 内存 phases 仍在,落库会被 SQL allowlist 拒绝(`persistCommittedPhase` 非 strict)。
+
+## 未做
+
+- 本提交先落 BUG-633/634。未推的 BUG-626/627(`e396c883`)按任务书「决策 5 叠在本单客户端改动上」随后 cherry-pick。
+- 未领取、未实现 BUG-628。
+- 未提升 `main`。Skill 未升版。
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index 99abc955..f0852270 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -94,7 +94,7 @@
| `TASK-rectification-domain-alias-audit-20260909.md` | `PROGRESS-rectification-domain-alias-audit-20260909.md` | 领域命名审计:健康线 `health`(账本/焦点)与 `health_pressure`(计划/引擎)在十处比较里六处未归并(holdout declined、reverse-verify、conflict probes、probeYearAlreadyCovered、引擎 oos_blind_prompts / _event_years / volunteered);职业线焦点存为 `other` 只靠 questionId 兜底。其余六领域三层同名无问题。决策:TS/Python 各一个归并函数 + 源码合同禁字面量比较 | 待执行 | `codex/rectification-domain-alias-audit-20260909`(BUG-628) |
| `TASK-rectification-yearless-probe-downgrade-20260909.md` | `PROGRESS-rectification-yearless-probe-downgrade-20260909.md` | 产品拍板:无年月性格题(D9/D10 风格、月宿边界)降级为平局裁决——带年月题问完且候选仍分不开才问,分值减半(±1)、不计淘汰,报告标「参考」;三列卡性格描述不动;新增离线命中率测量脚本 + 导出 SQL(有出生证用户,聚合);Skill 10.0.21 | 已验收通过 | `fef54b6e`(BUG-629,Skill 10.0.21) |
| `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待验收 | `codex/rectification-followups-20260909`(BUG-631~632) |
-| `TASK-rectification-evidence-turn-empty-answer-20260910.md` | `PROGRESS-rectification-evidence-turn-empty-answer-20260910.md` | 证据轮模型无正文被判整轮失败:证据、评分、下一问都已落库却只剩『没有拿到下一个问题』(BUG-633);答题旁白只说『范围没变』、时间线写死『还在收窄』(BUG-634) | 待执行 |
+| `TASK-rectification-evidence-turn-empty-answer-20260910.md` | `PROGRESS-rectification-evidence-turn-empty-answer-20260910.md` | 证据轮模型无正文被判整轮失败:证据、评分、下一问都已落库却只剩『没有拿到下一个问题』(BUG-633);答题旁白只说『范围没变』、时间线写死『还在收窄』(BUG-634) | 待验收 |
### 聊天主链路与首页
diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md
index 165028e5..6beabfa7 100644
--- a/docs/testing/rectification-scenarios-20260907.md
+++ b/docs/testing/rectification-scenarios-20260907.md
@@ -187,3 +187,13 @@
- 交付后展开验证报告,「D9 / D10 类型对照」和「月宿边界」状态是 `reference`,说明是性格自评、只作排序参考、不参与淘汰
- 三列对照卡上的性格句子仍按类型表展示,不改写
+## 8. 证据轮模型没写正文时必须出现「记下了」和下一问
+
+资料与开场同第 0 条。虚构经历说到搬家或入职一件带年月的事。
+
+期望:
+
+- 助手正文出现「记下了」加这件事的复述,不得只剩「没有拿到下一个问题」
+- 下一问可见(气泡里或主持人问题行),输入框在问题可见时才写「请回答上面的问题…」
+- 点选题范围没变时应能看到领先或落后;全零才只说「范围没变」
+- 顶部时间线不得写「还在收窄」
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 336c9e6c..abcc801e 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -227,6 +227,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | enabled (typing queues), stop visible |
| `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled |
| `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card |
+| `question-gap`, persisted question | snapshot already has the next prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” |
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts
index c42d0eb2..fad85716 100644
--- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts
+++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts
@@ -214,5 +214,8 @@ function turnReceipt(
methods: receipt.methods,
started_at: receipt.startedAt,
completed_at: receipt.completedAt,
+ ...(receipt.phases.some((phase) => phase.phase === "answer.host_fallback")
+ ? { answer_origin: "host_fallback" as const }
+ : {}),
};
}
diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx
index 5178bf01..e13bd38f 100644
--- a/frontend/src/components/conversational-birth-time-rectification.tsx
+++ b/frontend/src/components/conversational-birth-time-rectification.tsx
@@ -27,6 +27,7 @@ export type PersistedRectificationTurn = Readonly<{
methods?: readonly string[];
skill_name?: string;
skill_version?: string;
+ answer_origin?: "host_fallback";
}> | null;
}>;
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 6d4666ae..7c1ace3c 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -60,6 +60,8 @@ import {
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionGapState,
+ rectificationReadonlyRangeCopy,
+ contrastProbesFromReceipt,
searchWindowFromSnapshot,
caseStageFromSnapshot,
type RectificationCaseSnapshotPayload,
@@ -177,12 +179,23 @@ function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | n
function RectificationReadonlyRange({
range,
+ sessionOutcome,
+ discriminatingEventProbes,
+ contrastProbes,
}: Readonly<{
range: readonly [string, string];
+ sessionOutcome: string | null;
+ discriminatingEventProbes: unknown;
+ contrastProbes: unknown;
}>) {
return (
- 目前范围 {range[0]}–{range[1]},还在收窄
+ {rectificationReadonlyRangeCopy({
+ range,
+ sessionOutcome,
+ discriminatingEventProbes,
+ contrastProbes,
+ })}
);
}
@@ -986,6 +999,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{ role: "assistant", text: parsed.text },
]);
onCompleted?.();
+ } else {
+ await loadCaseSnapshot((turns) => {
+ if (turns.length) {
+ setMessages((current) => mergeTurnQuestions(current, turns));
+ }
+ });
}
} catch (caught) {
frames.settle();
@@ -1487,6 +1506,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
liveQuestionVisible: liveQuestionOnMessages,
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable",
+ questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus"),
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
nextUserActionId,
busy,
@@ -1726,11 +1746,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
)}
);
})}
+ {questionGap === "persisted_question" && currentQuestion?.prompt && (
+
+
+
{currentQuestion.prompt}
+
+
+ )}
{questionGap === "preparing" && (
@@ -1777,7 +1807,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
- : collectSpokenPrompt
+ : (liveQuestionOnMessages || questionGap === "persisted_question") && collectSpokenPrompt
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts
index fcd48193..a7c3ae98 100644
--- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts
+++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts
@@ -59,6 +59,12 @@ import {
type PublicStreamEvent,
} from "./stream-mapping";
import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic";
+import {
+ batchResultFromToolChunk,
+ composeHostFallbackNarration,
+ lastCompletedPublicTool,
+ publicWriteToolCompleted,
+} from "./host-fallback";
import {
applyStepAnswerChunk,
createStepAnswerState,
@@ -224,8 +230,14 @@ function isRetryableError(errorCode: string): boolean {
return RETRYABLE_ERROR_CODES.has(errorCode);
}
-function shouldAutoRetry(errorCode: string, signal?: AbortSignal): boolean {
- return !signal?.aborted && isRetryableError(errorCode);
+function shouldAutoRetry(
+ errorCode: string,
+ signal?: AbortSignal,
+ status?: AttemptStatus,
+): boolean {
+ if (signal?.aborted) return false;
+ if (errorCode === "empty_stream") return status === "retryable";
+ return isRetryableError(errorCode);
}
function clockWindow(range: { start_time?: string | null; end_time?: string | null } | null | undefined): string | null {
@@ -484,7 +496,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise | null = null;
let answerText = "";
const answerDeltas: string[] = [];
const phases: string[] = [];
const toolsUsed = new Set();
const events: PublicStreamEvent[] = [];
const toolTerminalStatus = new Map();
+ let batchToolResult: unknown = null;
+ let hostFallbackUsed = false;
const emittedKeys = new Set();
const emittedActivities = new Set();
const repeatedCalls = new Map();
@@ -771,7 +786,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise | null = null;
const stepAnswer = createStepAnswerState();
let spokenRaw = "";
@@ -819,6 +833,20 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise => {
+ if (answerText.trim()) return false;
+ if (toolTerminalStatus.get("rectification-record-evidence-batch") !== "completed") {
+ return false;
+ }
+ const spoken = composeHostFallbackNarration(batchToolResult ?? {});
+ if (!spoken) return false;
+ hostFallbackUsed = true;
+ await recordPhase("answer.host_fallback");
+ await publish({ type: "answer.host_fallback" });
+ await emitVisibleSpoken(spoken);
+ return true;
+ };
+
try {
for await (const chunk of result.fullStream) {
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
@@ -868,6 +896,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise;
+
+export function publicWriteToolCompleted(
+ status: ReadonlyMap,
+): boolean {
+ return HOST_FALLBACK_WRITE_TOOLS.some((tool) => status.get(tool) === "completed");
+}
+
+export function lastCompletedPublicTool(
+ status: ReadonlyMap,
+): string | null {
+ let last: string | null = null;
+ for (const [tool, value] of status) {
+ if (value === "completed") last = tool;
+ }
+ return last;
+}
+
+function recapLine(item: HostFallbackRecap): string {
+ const label = typeof item.display_date_label === "string" ? item.display_date_label.trim() : "";
+ const phrase = typeof item.event_phrase === "string" ? item.event_phrase.trim() : "";
+ return [label, phrase].filter(Boolean).join(" ").trim();
+}
+
+function recapsFromBatchResult(value: unknown): HostFallbackRecap[] {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
+ const row = value as {
+ accepted_recaps?: unknown;
+ items?: unknown;
+ };
+ if (Array.isArray(row.accepted_recaps)) {
+ return row.accepted_recaps.flatMap((item) => {
+ if (!item || typeof item !== "object" || Array.isArray(item)) return [];
+ return [item as HostFallbackRecap];
+ });
+ }
+ if (!Array.isArray(row.items)) return [];
+ return row.items.flatMap((item) => {
+ if (!item || typeof item !== "object" || Array.isArray(item)) return [];
+ const entry = item as HostFallbackRecap & { outcome?: unknown };
+ if (entry.outcome && entry.outcome !== "accepted") return [];
+ if (!entry.display_date_label && !entry.event_phrase) return [];
+ return [entry];
+ });
+}
+
+export function batchResultFromToolChunk(chunk: {
+ type?: string;
+ payload?: { toolName?: unknown; result?: unknown; output?: unknown };
+ object?: unknown;
+}): unknown {
+ if (chunk.type !== "tool-result") return null;
+ if (chunk.payload?.toolName !== "rectification-record-evidence-batch") return null;
+ return chunk.payload?.result ?? chunk.payload?.output ?? chunk.object ?? null;
+}
+
+export function composeHostFallbackNarration(batchResult: unknown): string | null {
+ if (batchResult == null) return null;
+ const lines = recapsFromBatchResult(batchResult)
+ .map(recapLine)
+ .filter(Boolean);
+ if (lines.length > 0) return `记下了:${lines.join("、")}。`;
+ return "记下了。";
+}
diff --git a/frontend/src/lib/rectification-agentic/v9/message-origin.ts b/frontend/src/lib/rectification-agentic/v9/message-origin.ts
index a24b76d1..345b95c0 100644
--- a/frontend/src/lib/rectification-agentic/v9/message-origin.ts
+++ b/frontend/src/lib/rectification-agentic/v9/message-origin.ts
@@ -7,6 +7,7 @@ export const RECTIFICATION_MESSAGE_ORIGINS = [
"voice_input",
"retry_replay",
"system_recovery",
+ "host_fallback",
] as const;
export type RectificationMessageOrigin = (typeof RECTIFICATION_MESSAGE_ORIGINS)[number];
diff --git a/frontend/src/lib/rectification-agentic/v9/probe-explain.ts b/frontend/src/lib/rectification-agentic/v9/probe-explain.ts
index bf18ce54..70f8bea3 100644
--- a/frontend/src/lib/rectification-agentic/v9/probe-explain.ts
+++ b/frontend/src/lib/rectification-agentic/v9/probe-explain.ts
@@ -211,15 +211,23 @@ export function clusterScoreDeltas(
return [...summed.values()].filter((item) => item.delta !== 0);
}
+function clockSpanLabel(range: readonly [string, string]): string {
+ const start = range[0]?.slice(0, 5) ?? "";
+ const end = range[1]?.slice(0, 5) ?? "";
+ if (!CLOCK.test(start)) return "";
+ if (!CLOCK.test(end) || start === end) return start;
+ return `${start}–${end}`;
+}
+
export function explainScoreMovement(deltas: readonly ClusterScoreDelta[]): string {
if (deltas.length === 0) return "";
const rising = [...deltas].filter((item) => item.delta > 0).sort((a, b) => b.delta - a.delta)[0] ?? null;
const falling = [...deltas].filter((item) => item.delta < 0).sort((a, b) => a.delta - b.delta)[0] ?? null;
- const up = rising ? formatClusterRange(rising.range) : "";
- const down = falling ? formatClusterRange(falling.range) : "";
- if (up && down) return `${up}领先,${down}落后`;
- if (up) return `${up}领先`;
- if (down) return `${down}落后`;
+ const up = rising ? clockSpanLabel(rising.range) : "";
+ const down = falling ? clockSpanLabel(falling.range) : "";
+ if (up && down) return `${up} 领先,${down} 落后`;
+ if (up) return `${up} 领先`;
+ if (down) return `${down} 落后`;
return "";
}
diff --git a/frontend/src/lib/rectification-agentic/v9/public-receipt.ts b/frontend/src/lib/rectification-agentic/v9/public-receipt.ts
index 5a6c9bf4..57cbb42a 100644
--- a/frontend/src/lib/rectification-agentic/v9/public-receipt.ts
+++ b/frontend/src/lib/rectification-agentic/v9/public-receipt.ts
@@ -23,6 +23,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
"candidate.accepted",
"birth_time.confirmed",
"answer.composed",
+ "answer.host_fallback",
"billing.settled",
"answer.delta",
"activity.changed",
diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts
index 48e99a04..6a040d12 100644
--- a/frontend/src/lib/rectification-surface-state.ts
+++ b/frontend/src/lib/rectification-surface-state.ts
@@ -136,7 +136,16 @@ export function parsePersistedRectificationTurns(value: unknown): PersistedRecti
if (!Array.isArray(value)) return [];
return value.map((turn: RawTurn) => {
const receipt = turn?.receipt && typeof turn.receipt === "object"
- ? turn.receipt as { status?: unknown; phases?: unknown; tools?: unknown; methods?: unknown; skill_name?: unknown; skill_version?: unknown; tool_activities?: unknown }
+ ? turn.receipt as {
+ status?: unknown;
+ phases?: unknown;
+ tools?: unknown;
+ methods?: unknown;
+ skill_name?: unknown;
+ skill_version?: unknown;
+ tool_activities?: unknown;
+ answer_origin?: unknown;
+ }
: null;
const toolActivities = parseToolActivities(receipt?.tool_activities);
return {
@@ -154,6 +163,7 @@ export function parsePersistedRectificationTurns(value: unknown): PersistedRecti
skill_name: typeof receipt.skill_name === "string" ? receipt.skill_name : undefined,
skill_version: typeof receipt.skill_version === "string" ? receipt.skill_version : undefined,
...(toolActivities ? { tool_activities: toolActivities } : {}),
+ ...(receipt.answer_origin === "host_fallback" ? { answer_origin: "host_fallback" as const } : {}),
} : null,
};
});
@@ -245,7 +255,12 @@ export function rectificationConversationState(input: Readonly<{
return "empty";
}
-export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable" | "verified_idle";
+export type RectificationQuestionGapState =
+ | "idle"
+ | "preparing"
+ | "unavailable"
+ | "verified_idle"
+ | "persisted_question";
export type RectificationQuestionGapInput = Readonly<{
/** The current question is rendered live inside an assistant message. */
@@ -258,6 +273,11 @@ export type RectificationQuestionGapInput = Readonly<{
offerAwaitingReader?: boolean;
/** GET / structured-choice `next_user_action.id`; `start_consultation` means post-adopt verify is done. */
nextUserActionId?: string | null;
+ /**
+ * Snapshot has a current question from a persisted focus, even if no
+ * settled assistant message carries it yet.
+ */
+ questionPersisted?: boolean;
busy: boolean;
readonly: boolean;
regenerating: boolean;
@@ -282,6 +302,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
+ if (input.questionPersisted) return "persisted_question";
if (input.nextUserActionId === "start_consultation") return "verified_idle";
if (input.questionLoadFailed) return "unavailable";
// Either the snapshot names no question, or it names one that no settled
@@ -294,6 +315,39 @@ export function rectificationQuestionRetryActive(state: RectificationQuestionGap
return state === "preparing";
}
+function probeHasYear(value: unknown): boolean {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const year = (value as { year?: unknown }).year;
+ return typeof year === "number" && Number.isFinite(year) && year > 0;
+}
+
+function listHasDatedProbe(value: unknown): boolean {
+ if (!Array.isArray(value)) return false;
+ return value.some(probeHasYear);
+}
+
+export function contrastProbesFromReceipt(receipt: Readonly> | null | undefined): unknown {
+ const packet = receipt?.candidate_contrast_packet;
+ if (!packet || typeof packet !== "object" || Array.isArray(packet)) return null;
+ return (packet as { probes?: unknown }).probes ?? null;
+}
+
+/** Timeline range line. Never says 收窄. */
+export function rectificationReadonlyRangeCopy(input: Readonly<{
+ range: readonly [string, string];
+ sessionOutcome?: string | null;
+ discriminatingEventProbes?: unknown;
+ contrastProbes?: unknown;
+}>): string {
+ const datedOpen = listHasDatedProbe(input.discriminatingEventProbes)
+ || listHasDatedProbe(input.contrastProbes);
+ const choicesExhausted = input.sessionOutcome === "discriminate_candidates" && !datedOpen;
+ const suffix = choicesExhausted
+ ? "选择题已问完,再补带年月的经历才会变"
+ : "还在核对";
+ return `目前范围 ${input.range[0]}–${input.range[1]},${suffix}`;
+}
+
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
export function rectificationAdoptingLabel(time: string): string {
return `正在采用 ${time}…`;
diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts
index f2a3a5af..2d5e0188 100644
--- a/frontend/src/mastra/rectification-v9-tools.ts
+++ b/frontend/src/mastra/rectification-v9-tools.ts
@@ -49,6 +49,7 @@ import {
isEvidenceDomain,
isDatePrecision,
displayDateLabel,
+ eventPhraseFromSummary,
evidenceSubjectForDomain,
applyOccupationCollectLedgerNorm,
} from "@/lib/rectification-agentic/v9/evidence-model";
@@ -1484,6 +1485,18 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const rescore = result.acceptedCount > 0
? await autoRescoreAfterEvidenceChange(input.caseId)
: { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null };
+ const acceptedRecaps = scoringItems.flatMap(({ item }, offset) => {
+ const recorded = result.items[offset];
+ if (!recorded || recorded.outcome !== "accepted") return [];
+ const label = displayDateLabel(
+ item.datePrecision,
+ item.occurredFrom ?? null,
+ item.occurredTo ?? null,
+ );
+ const phrase = eventPhraseFromSummary(item.summary);
+ if (!label && !phrase) return [];
+ return [{ display_date_label: label, event_phrase: phrase }];
+ });
const projection = {
items: result.items.map((item) => ({
index: item.index,
@@ -1499,6 +1512,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
clarification_fields: item.clarificationFields,
error_code: item.errorCode === "quote_not_grounded" ? "quote_mismatch" : item.errorCode,
})),
+ accepted_recaps: acceptedRecaps,
accepted_count: result.acceptedCount,
needs_clarification_count: result.needsClarificationCount,
rejected_count: result.rejectedCount,
diff --git a/frontend/supabase/migrations/20260910010000_rectification_host_fallback_phase.sql b/frontend/supabase/migrations/20260910010000_rectification_host_fallback_phase.sql
new file mode 100644
index 00000000..54aa653e
--- /dev/null
+++ b/frontend/supabase/migrations/20260910010000_rectification_host_fallback_phase.sql
@@ -0,0 +1,98 @@
+-- BUG-633: persist answer.host_fallback when the host speaks after a
+-- completed evidence batch and the model emitted no answer text.
+
+begin;
+
+do $migration$
+begin
+ if current_user <> 'schema_owner' then
+ raise exception 'rectification_host_fallback_phase_requires_schema_owner'
+ using errcode = '42501';
+ end if;
+end
+$migration$;
+
+alter table public.agentic_rectification_run_phases
+ drop constraint if exists agentic_rectification_run_phases_phase_check,
+ add constraint agentic_rectification_run_phases_phase_check check (
+ phase in (
+ 'run.started', 'skill.started', 'skill.loaded', 'skill.bound', 'case.loaded',
+ 'intent.classified', 'evidence.proposed', 'evidence.confirmed',
+ 'candidates.comparing', 'candidates.updated', 'diagnostics.completed',
+ 'candidate.accepted', 'birth_time.confirmed', 'answer.composed',
+ 'answer.host_fallback',
+ 'billing.settled', 'answer.delta', 'run.completed', 'run.failed'
+ )
+ );
+
+create or replace function public.insert_agentic_rectification_run_phase(
+ p_user_id uuid,
+ p_case_id uuid,
+ p_turn_id uuid,
+ p_phase text,
+ p_tool_name text,
+ p_sequence integer,
+ p_attempt_id uuid
+)
+returns jsonb
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_attempt public.agentic_rectification_run_attempts%rowtype;
+ v_phase_id uuid;
+begin
+ if p_user_id is null or p_case_id is null or p_turn_id is null or p_attempt_id is null
+ or p_phase not in (
+ 'run.started', 'skill.started', 'skill.loaded', 'skill.bound', 'case.loaded',
+ 'intent.classified', 'evidence.proposed', 'evidence.confirmed',
+ 'candidates.comparing', 'candidates.updated', 'diagnostics.completed',
+ 'candidate.accepted', 'birth_time.confirmed', 'answer.composed',
+ 'answer.host_fallback',
+ 'billing.settled', 'answer.delta', 'run.completed', 'run.failed'
+ ) then
+ raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
+ end if;
+ if not exists (
+ select 1 from public.agentic_rectification_cases
+ where id = p_case_id and user_id = p_user_id
+ ) then
+ raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
+ end if;
+ if not exists (
+ select 1 from public.agentic_rectification_turns
+ where id = p_turn_id and case_id = p_case_id
+ ) then
+ raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001';
+ end if;
+
+ select * into v_attempt
+ from public.agentic_rectification_run_attempts
+ where id = p_attempt_id and case_id = p_case_id and turn_id = p_turn_id
+ for update;
+ if not found then
+ raise exception 'agentic_rectification_attempt_not_found' using errcode = 'P0001';
+ end if;
+ if v_attempt.status <> 'started' then
+ raise exception 'agentic_rectification_attempt_not_started' using errcode = 'P0001';
+ end if;
+
+ insert into public.agentic_rectification_run_phases (
+ case_id, turn_id, attempt_id, phase, tool_name, sequence
+ ) values (
+ p_case_id, p_turn_id, p_attempt_id, p_phase, p_tool_name, coalesce(p_sequence, 0)
+ ) returning id into v_phase_id;
+
+ return jsonb_build_object('phase_id', v_phase_id, 'attempt_id', p_attempt_id);
+end;
+$$;
+
+revoke all on function public.insert_agentic_rectification_run_phase(
+ uuid, uuid, uuid, text, text, integer, uuid
+) from public, anon, authenticated;
+grant execute on function public.insert_agentic_rectification_run_phase(
+ uuid, uuid, uuid, text, text, integer, uuid
+) to service_role;
+
+commit;
diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts
index d8d0574c..75c2c05a 100644
--- a/frontend/tests/agent-voice-copy-contract.test.ts
+++ b/frontend/tests/agent-voice-copy-contract.test.ts
@@ -284,4 +284,7 @@ test("opening body lists domains without years and the stem no longer lists year
assert.ok(MACHINE_VOICE_LEXICON.includes("强相关"));
assert.ok(MACHINE_VOICE_LEXICON.includes("有关联"));
assert.ok(MACHINE_VOICE_LEXICON.includes("弱关联"));
+ assert.ok(MACHINE_VOICE_LEXICON.includes("还在收窄"));
+ const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
+ assert.doesNotMatch(chat, /还在收窄/);
});
diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts
index 3192fc1c..06499e2d 100644
--- a/frontend/tests/rectification-agentic-entry.test.ts
+++ b/frontend/tests/rectification-agentic-entry.test.ts
@@ -270,7 +270,15 @@ test("usage completes or releases without hiding settlement failures", () => {
assert.match(run, /answerTokens: 8_192/);
assert.match(run, /thinkingTokens: 8_192/);
assert.match(run, /activity.changed/);
- assert.match(run, /if \(!answerText\.trim\(\)\) return failedAttempt\(attemptId, "empty_stream"\)/);
+ // 原值: if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream")
+ // 新值: batch completed → applyHostFallback;否则 empty_stream 仅在零写工具时 retryable
+ // 原因: BUG-633 决策 1/2,不得把 empty_stream 整表放回 RETRYABLE_ERROR_CODES
+ assert.match(run, /applyHostFallback/);
+ assert.match(run, /composeHostFallbackNarration/);
+ assert.match(run, /errorCode: "empty_stream"/);
+ assert.match(run, /if \(errorCode === "empty_stream"\) return status === "retryable"/);
+ assert.doesNotMatch(run, /if \(!answerText\.trim\(\)\) return failedAttempt\(attemptId, "empty_stream"\)/);
+ assert.doesNotMatch(run, /RETRYABLE_ERROR_CODES = new Set\(\[[^\]]*empty_stream/);
assert.doesNotMatch(run, /bindSpokenToOpenQuestion|CHOICE_CARD_CONTINUATION_ACK|openQuestionPromptFromToolResult/);
assert.doesNotMatch(run, /heldSpoken/);
assert.match(run, /replace: true/);
@@ -484,6 +492,15 @@ test("the natal house table is a live board beside the chat, not a message", ()
assert.match(chat, / {
credibleAfter: ["04:31", "04:39"],
});
// 旧:领先/落后 +「范围从 04:31–05:07 收到 04:31–04:39」
- // 新:已记录,范围收到 A–B。不变则「范围没变」。变宽写「变为」。
- // 原因:BUG-606 决策 3(b),旁白不再讲哪段领先落后
+ // 新:范围变了仍只说收到/变为,不讲领先落后(BUG-606);范围不变才补领先/落后(BUG-634)
assert.equal(narrowed, "已记录,范围收到 04:31–04:39。");
assert.doesNotMatch(narrowed, /领先|落后/);
assert.doesNotMatch(narrowed, /范围从 /);
@@ -1320,7 +1319,28 @@ test("a scoring choice narrates cluster movement and range change", () => {
credibleBefore: ["04:31", "04:39"],
credibleAfter: ["04:31", "04:39"],
});
- assert.match(unchanged, /范围没变/);
+ assert.equal(unchanged, "已记录,范围没变;04:31–04:39 领先。");
+ const unchangedZero = composeChoiceNarration({
+ optionId: "A",
+ scoring: true,
+ appliedInference: true,
+ deltasByCluster: [{ range: ["04:31", "04:39"], delta: 0 }],
+ credibleBefore: ["04:31", "04:39"],
+ credibleAfter: ["04:31", "04:39"],
+ });
+ assert.equal(unchangedZero, "已记录,范围没变。");
+ const movement = composeChoiceNarration({
+ optionId: "A",
+ scoring: true,
+ appliedInference: true,
+ deltasByCluster: [
+ { range: ["04:52", "04:53"], delta: 4 },
+ { range: ["05:08", "05:12"], delta: -4 },
+ ],
+ credibleBefore: ["04:45", "05:15"],
+ credibleAfter: ["04:45", "05:15"],
+ });
+ assert.equal(movement, "已记录,范围没变;04:52–04:53 领先,05:08–05:12 落后。");
const widened = composeChoiceNarration({
optionId: "A",
scoring: true,
diff --git a/frontend/tests/rectification-host-fallback.test.ts b/frontend/tests/rectification-host-fallback.test.ts
new file mode 100644
index 00000000..dd4df3e8
--- /dev/null
+++ b/frontend/tests/rectification-host-fallback.test.ts
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import {
+ composeHostFallbackNarration,
+ publicWriteToolCompleted,
+} from "../src/lib/rectification-agentic/v9/host-fallback.ts";
+
+test("host fallback recap uses only batch return lines", () => {
+ assert.equal(composeHostFallbackNarration(null), null);
+ assert.equal(composeHostFallbackNarration({}), "记下了。");
+ assert.equal(
+ composeHostFallbackNarration({
+ accepted_recaps: [
+ { display_date_label: "2016年9月", event_phrase: "入学" },
+ { display_date_label: "2020年6月", event_phrase: "毕业" },
+ ],
+ }),
+ "记下了:2016年9月 入学、2020年6月 毕业。",
+ );
+ assert.equal(
+ composeHostFallbackNarration({
+ items: [
+ { outcome: "accepted", display_date_label: "2018年7月", event_phrase: "入职" },
+ { outcome: "rejected", display_date_label: "1999年", event_phrase: "不该出现" },
+ ],
+ }),
+ "记下了:2018年7月 入职。",
+ );
+});
+
+test("write-tool completion is limited to batch, set-focus and compare", () => {
+ const none = new Map([
+ ["rectification-read-case", "completed"],
+ ]);
+ assert.equal(publicWriteToolCompleted(none), false);
+ const batch = new Map([
+ ["rectification-record-evidence-batch", "completed"],
+ ]);
+ assert.equal(publicWriteToolCompleted(batch), true);
+ const failedBatch = new Map([
+ ["rectification-record-evidence-batch", "failed"],
+ ]);
+ assert.equal(publicWriteToolCompleted(failedBatch), false);
+});
+
+test("case snapshot receipts expose host_fallback origin from the durable phase", () => {
+ const route = readFileSync(
+ new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url),
+ "utf8",
+ );
+ assert.match(route, /answer_origin: "host_fallback"/);
+ assert.match(route, /answer\.host_fallback/);
+});
diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts
index 64ccf447..cb5c9360 100644
--- a/frontend/tests/rectification-spoken-collect.test.ts
+++ b/frontend/tests/rectification-spoken-collect.test.ts
@@ -162,7 +162,10 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic
assert.doesNotMatch(chat, /showCollectSpokenPrompt/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.doesNotMatch(chat, /showQuestionSlot/);
- assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
+ assert.match(
+ chat,
+ /\(liveQuestionOnMessages \|\| questionGap === "persisted_question"\) && collectSpokenPrompt\n\s+\? "请回答上面的问题…"/,
+ );
assert.match(
readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"),
/\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/,
diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts
index 0dd2e9ea..4ea737bc 100644
--- a/frontend/tests/rectification-surface-state.test.ts
+++ b/frontend/tests/rectification-surface-state.test.ts
@@ -14,6 +14,7 @@ import {
rectificationInitialLiveLabel,
rectificationQuestionGapState,
rectificationQuestionRetryActive,
+ rectificationReadonlyRangeCopy,
type RectificationQuestionGapInput,
} from "../src/lib/rectification-surface-state.ts";
@@ -71,6 +72,48 @@ test("question gap: nothing is shown while busy, readonly, regenerating, or for
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle");
});
+test("question gap: a persisted focus question is shown instead of preparing", () => {
+ assert.equal(
+ rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionPersisted: true }),
+ "persisted_question",
+ );
+ assert.equal(
+ rectificationQuestionGapState({
+ ...gapBase,
+ liveQuestionVisible: true,
+ questionMissing: false,
+ questionPersisted: true,
+ }),
+ "idle",
+ );
+ assert.equal(rectificationQuestionRetryActive("persisted_question"), false);
+});
+
+test("readonly range copy never says 收窄 and reports exhausted choices from the snapshot", () => {
+ assert.equal(
+ rectificationReadonlyRangeCopy({
+ range: ["04:45", "05:15"],
+ sessionOutcome: "discriminate_candidates",
+ discriminatingEventProbes: [],
+ contrastProbes: [],
+ }),
+ "目前范围 04:45–05:15,选择题已问完,再补带年月的经历才会变",
+ );
+ const stillOpen = rectificationReadonlyRangeCopy({
+ range: ["04:45", "05:15"],
+ sessionOutcome: "discriminate_candidates",
+ discriminatingEventProbes: [{ year: 2016 }],
+ });
+ assert.match(stillOpen, /还在核对/);
+ assert.doesNotMatch(stillOpen, /收窄/);
+ const collecting = rectificationReadonlyRangeCopy({
+ range: ["04:45", "05:15"],
+ sessionOutcome: "collect_evidence",
+ });
+ assert.match(collecting, /还在核对/);
+ assert.doesNotMatch(collecting, /选择题已问完|收窄/);
+});
+
test("live-row labels follow the action that started the turn", () => {
assert.equal(rectificationInitialLiveLabel("opening"), "正在读取你的出生资料,准备第一个问题…");
assert.equal(rectificationInitialLiveLabel("message"), "正在处理…");
@@ -109,6 +152,7 @@ test("persisted turns keep their question, offer and tool activities through the
tools: ["rectification-compare-candidates"],
methods: ["d9-navamsa", "d10-dashamsa"],
tool_activities: [{ tool: "rectification-compare-candidates", status: "completed", methods: ["d9-navamsa"], started_at: null, elapsed_ms: 12 }],
+ answer_origin: "host_fallback",
},
},
{ id: "t2", role: "user", text: "2014 年毕业。" },
@@ -119,6 +163,7 @@ test("persisted turns keep their question, offer and tool activities through the
assert.deepEqual(turns[0]?.question, { focus_id: "f1", kind: "choice" });
assert.deepEqual(turns[0]?.receipt?.methods, ["d9-navamsa", "d10-dashamsa"]);
assert.equal(turns[0]?.receipt?.tool_activities?.[0]?.tool, "rectification-compare-candidates");
+ assert.equal(turns[0]?.receipt?.answer_origin, "host_fallback");
assert.equal(turns[1]?.role, "user");
assert.equal(turns[1]?.status, "completed");
assert.equal(turns[1]?.receipt, null);
diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts
index 659cee82..3b83f73f 100644
--- a/frontend/tests/rectification-v9-migration.test.ts
+++ b/frontend/tests/rectification-v9-migration.test.ts
@@ -1514,3 +1514,36 @@ test("VedAstro retry RPC accepts only safe validation fields and mutates only th
assert.match(update, /set decision_receipt = v_receipt,\s+updated_at = pg_catalog\.now\(\)/);
assert.doesNotMatch(update, /candidates\s*=|inference_state\s*=|selected_time\s*=|selected_candidate_id\s*=/);
});
+
+test("host-fallback phase migration is forward-only and does not rewrite the frozen V10 allowlist", () => {
+ const filename = "20260910010000_rectification_host_fallback_phase.sql";
+ const migration = readFileSync(
+ new URL(`../supabase/migrations/${filename}`, import.meta.url),
+ "utf8",
+ );
+ assert.match(migration, /^begin;[\s\S]*^commit;$/m);
+ assert.equal(
+ existsSync(fileURLToPath(new URL(`../db/migrations/${filename}`, import.meta.url))),
+ false,
+ "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
+ );
+ assert.match(migration, /rectification_host_fallback_phase_requires_schema_owner/);
+ assert.match(migration, /'answer.host_fallback'/);
+ assert.match(
+ migration,
+ /create or replace function public\.insert_agentic_rectification_run_phase\(/,
+ );
+ assert.match(
+ migration,
+ /grant execute on function public\.insert_agentic_rectification_run_phase\([\s\S]*to service_role/,
+ );
+ assert.equal(rectificationV10Phases.includes("answer.host_fallback"), false);
+ assert.match(rectificationV10Migration, /'answer.composed'/);
+ assert.doesNotMatch(
+ rectificationV10Migration.slice(
+ rectificationV10Migration.indexOf("create or replace function public.insert_agentic_rectification_run_phase("),
+ rectificationV10Migration.indexOf("create or replace function public.insert_agentic_rectification_tool_receipt("),
+ ),
+ /answer\.host_fallback/,
+ );
+});
diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts
index 24544fe1..8484466d 100644
--- a/frontend/tests/rectification-v9-stream.test.ts
+++ b/frontend/tests/rectification-v9-stream.test.ts
@@ -605,7 +605,10 @@ test("Chinese process self-talk after tools is thinking, not the spoken answer",
assert.equal(result.answerText, "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?");
});
-test("process-only self-talk after tools fails closed instead of becoming the spoken answer", async () => {
+test("process-only self-talk after a completed batch uses the host fallback answer", async () => {
+ // 原值: empty_stream → ok=false,不计费,run.failed
+ // 新值: batch completed 且无正文 → 记下了。,phases 含 answer.host_fallback,计费 complete 一次
+ // 原因: BUG-633 决策 1,证据已落库不得因模型没写正文整轮失败
let buildCount = 0;
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
const accounting = fakeAccounting({
@@ -638,16 +641,18 @@ test("process-only self-talk after tools fails closed instead of becoming the sp
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 1);
- assert.equal(result.ok, false);
- assert.equal(result.errorCode, "empty_stream");
- assert.equal(result.answerText, "");
+ assert.equal(result.ok, true);
+ assert.equal(result.errorCode, null);
+ assert.equal(result.answerText, "记下了。");
+ assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.doesNotMatch(JSON.stringify(emitted), /我需要用批量工具/);
- assert.equal(emitted.some((event) => event.type === "run.failed"), true);
- assert.equal(emitted.some((event) => event.type === "run.completed"), false);
+ assert.equal(emitted.some((event) => event.type === "run.failed"), false);
+ assert.equal(emitted.some((event) => event.type === "run.completed"), true);
const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
- assert.equal(finalizedTurn?.args.p_status, "failed");
- assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
+ assert.equal(finalizedTurn?.args.p_status, "completed");
+ assert.equal(finalizedTurn?.args.p_assistant_message, "记下了。");
+ assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
@@ -824,7 +829,110 @@ test("browser disconnect aborts the run, finalizes retryable and releases usage"
assert.equal(billing.released, 1);
});
-test("empty stream fails closed instead of synthesizing an answer", async () => {
+test("empty stream with no write tools retries once then fails closed", async () => {
+ // 原值: 第一次 empty_stream 直接 failed,不发 attempt.reset
+ // 新值: 零公开写工具时 retryable,两轮都空才失败
+ // 原因: BUG-633 决策 2;fe87a9ec 仍禁止在已完成写工具后把 empty_stream 放进 RETRYABLE_ERROR_CODES
+ let buildCount = 0;
+ const { options, emitted, billing } = runOptions({
+ buildAgent: async () => {
+ buildCount += 1;
+ return fakeAgentStream([
+ 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("finish"),
+ ]) as never;
+ },
+ });
+ const result = await runV9AgentTurn(options);
+ assert.equal(buildCount, 2);
+ assert.equal(result.ok, false);
+ assert.equal(result.errorCode, "empty_stream");
+ assert.equal(result.answerText, "");
+ assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
+ assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
+ assert.equal(emitted.some((event) => event.type === "run.failed"), true);
+ assert.equal(emitted.some((event) => event.type === "run.completed"), false);
+});
+
+test("host fallback after a completed batch recaps the returned events", 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 () => fakeAgentStream([
+ 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-record-evidence-batch", args: { caseId: CASE_ID } }),
+ chunk("tool-result", {
+ toolName: "rectification-record-evidence-batch",
+ result: {
+ accepted_recaps: [
+ { display_date_label: "2016年9月", event_phrase: "入学" },
+ { display_date_label: "2020年6月", event_phrase: "毕业" },
+ ],
+ },
+ }),
+ chunk("finish"),
+ ]) as never,
+ });
+ const result = await runV9AgentTurn(options);
+ assert.equal(result.ok, true);
+ assert.equal(result.answerText, "记下了:2016年9月 入学、2020年6月 毕业。");
+ assert.ok(result.phases.includes("answer.host_fallback"));
+ assert.equal(emitted.some((event) => event.type === "run.completed"), true);
+ assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
+ const phases = accounting.calls
+ .filter((call) => call.fn === "insert_agentic_rectification_run_phase")
+ .map((call) => call.args.p_phase);
+ assert.ok(phases.includes("answer.host_fallback"));
+});
+
+test("empty stream without a write tool retries and the second attempt can succeed", async () => {
+ let buildCount = 0;
+ const { options, emitted, billing } = runOptions({
+ buildAgent: async () => {
+ buildCount += 1;
+ return buildCount === 1
+ ? fakeAgentStream([
+ 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("finish"),
+ ]) as never
+ : fakeAgentStream([
+ 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("text-delta", { text: "记下了这件事。" }),
+ chunk("finish"),
+ ]) as never;
+ },
+ });
+ const result = await runV9AgentTurn(options);
+ assert.equal(buildCount, 2);
+ assert.equal(result.ok, true);
+ assert.equal(result.answerText, "记下了这件事。");
+ assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
+ assert.ok(!result.phases.includes("answer.host_fallback"));
+ assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
+});
+
+test("length finish after a completed batch with no text uses the host fallback", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
@@ -832,17 +940,44 @@ test("empty stream fails closed instead of synthesizing an answer", async () =>
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-record-evidence-batch", args: { caseId: CASE_ID } }),
+ chunk("tool-result", {
+ toolName: "rectification-record-evidence-batch",
+ result: {
+ accepted_recaps: [
+ { display_date_label: "2018年7月", event_phrase: "入职" },
+ ],
+ },
+ }),
+ chunk("finish", { stepResult: { reason: "length" } }),
+ ]) as never,
+ });
+ const result = await runV9AgentTurn(options);
+ assert.equal(result.ok, true);
+ assert.equal(result.answerText, "记下了:2018年7月 入职。");
+ assert.ok(result.phases.includes("answer.host_fallback"));
+ assert.equal(emitted.some((event) => event.type === "run.failed"), false);
+ assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
+});
+
+test("set-focus without a batch and no text fails empty_stream without retrying", async () => {
+ const { options, emitted, billing } = runOptions({
+ buildAgent: async () => fakeAgentStream([
+ 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-result", { toolName: "rectification-set-focus" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "empty_stream");
- assert.equal(result.answerText, "");
- assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
- assert.equal(emitted.some((event) => event.type === "run.failed"), true);
- assert.equal(emitted.some((event) => event.type === "run.completed"), false);
+ assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("legacy Skill identity fails before billing reservation", async () => {
@@ -1162,7 +1297,10 @@ test("does not retry set-focus with identical arguments", async () => {
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
-test("duplicate compare after diagnostics fails closed without resetting the attempt", async () => {
+test("duplicate compare after diagnostics does not reset and host-falls back when the model emits no text", async () => {
+ // 原值: empty_stream failed,不计费
+ // 新值: batch 已完成 → 主持人兜底成功;重复 compare 仍只记一次 started、不 attempt.reset
+ // 原因: BUG-633 决策 1;BUG-368 重复 compare 不得 reset
const executedMethods = [
"ashtakavarga",
"d1-rashi",
@@ -1201,19 +1339,20 @@ test("duplicate compare after diagnostics fails closed without resetting the att
const result = await runV9AgentTurn(options);
- assert.equal(result.ok, false);
- assert.equal(result.errorCode, "empty_stream");
- assert.equal(result.answerText, "");
+ assert.equal(result.ok, true);
+ assert.equal(result.errorCode, null);
+ assert.equal(result.answerText, "记下了。");
+ assert.ok(result.phases.includes("answer.host_fallback"));
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
- assert.equal(emitted.some((event) => event.type === "run.failed"), true);
- assert.equal(emitted.some((event) => event.type === "run.completed"), false);
+ assert.equal(emitted.some((event) => event.type === "run.failed"), false);
+ assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(
emitted.filter((event) => event.type === "tool.activity"
&& (event as { tool?: string; status?: string }).tool === "rectification-compare-candidates"
&& (event as { tool?: string; status?: string }).status === "started").length,
1,
);
- assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
+ assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("an unclaimed V10 attempt never starts the model", async () => {