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 () => {