diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f8bfa5..52be9745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 校正时系统没接上会直说,记下经历后没重算也会说明 + +生时校正里,如果这边没判断出你在答哪一问,会请你把刚才那句再发一次,而不是说你没说清。记下经历后如果这次没能重新比较候选,会告诉你经历已经记下、稍后再比,不会假装范围已经在收。Skill 版本不变。 + ## 2026-09-15 — 外部占星服务额度用尽时马上说明,不再空等 解读若要用外部占星服务、而免费额度已经用完,会马上说明这次没拿到,不再空等几分钟。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0374bbf6..1cdf0d20 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11205,3 +11205,52 @@ - 相关记录:ERR-108、BUG-065、BUG-161、BUG-718 - 复发自:无 - 修复版本:待发布 + +## BUG-722 | 意图分类器两次异常被说成用户没说清,经历被丢掉 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-16 +- 影响面:`classifyTurnIntentWithRetry`、`POST /api/rectification/agent` 点选题与 `ask_candidate_discriminator` 分支 +- 用户现象:点选题下回一句带年月的经历,助手说「我不太确定这句是不是在回答上面的问题」。焦点仍在,但这句话没有当成经历记下,用户只能自己再说一遍。 +- 触发条件:`action=message` 且当前焦点是点选题;意图分类器两次尝试都抛异常(超时、供应商 5xx、网络抖动),或两次都解析不出结构化结果。 +- 根因:`classified === null`(模型没答上来)与 `classified.intent === "unclear"`(用户确实说不清)被合并进同一条分支,回 `unclearFocusReply` 并落库。采集题分支在 BUG-643 已把 null 记成 `collectIntent=unclassified` 并 fail-open;点选题没有跟上。 +- 修复:`classifyTurnIntentWithRetry` 增加 `outcome: classified | unclear | classifier_unavailable`。点选题与 discriminator 分支:`unclear` 维持原回复;`classifier_unavailable` 回「这边没接上,把刚才那句再发一次就行。」,不写证据、不推进焦点、不扣点。服务端打 `rectification_classifier_unavailable` 日志(只含耗时与次数,不含原文或案例 ID)。采集题 `collectIntent=unclassified` 语义不变。不得用关键词/正则兜底。 +- 验证:`frontend/tests/rectification-turn-intent-classifier.test.ts`:两次抛异常 → `outcome=classifier_unavailable`;模型返回 `intent: unclear` → `outcome=unclear`;两条路由文案不同。源码合同禁止 `!classified || classified.intent === "unclear"`。 +- 防复发:路由不得把分类器 null 与用户 `unclear` 合并。分类器失败不得回退到关键词、正则或词表。 +- 相关记录:BUG-643、BUG-635、BUG-522 +- 复发自:无(BUG-522 记录末尾写明「意图分类器超时仍是既有缺口,本单不修」,本单关闭该缺口) +- 修复版本:待发布 + +## BUG-723 | 校正引擎 429 被当成引擎坏了,重算静默失败、范围不动 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-16 +- 影响面:`engine-client.ts` `readEngineJson`、`runV9CandidateScore` / `runV9BlockScan`、`autoRescoreAfterEvidenceChange`、证据轮主持人正文 +- 用户现象:记下经历后助手写「记下了:…」,范围一动不动。没有「这次没有重新比较」之类的说明。只在两个人同时校正、重计算闸门饱和时出现。 +- 触发条件:`record-evidence-batch` 已 accepted 后自动重算;Python 返回 429 + `Retry-After` + `ERR_COMPUTE_BUSY`。 +- 根因:所有非 2xx 压成 `engine_request_failed`。`autoRescoreAfterEvidenceChange` 整段 `try/catch` 返回 `status=failed` 且不打日志;系统提示词要求工具静默,模型继续写「记下了」。 +- 修复:引擎调用按 `busy / http_error / timeout / bad_payload` 分档,读 `Retry-After`,每种非 ok 打 `console.warn`(路径、状态码或错误名、耗时;不含出生资料)。score / block_scan 对 `busy` 按 `Retry-After` 退避,上限 `RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT=2`、总退避 `RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS=6_000`。仍失败时 projection 带回 `error_kind`,主持人正文写「这次没有重新比较」,并去掉「收窄」类进度句。不改并发闸门。 +- 验证:`frontend/tests/rectification-v9-engine-contract.test.ts` 桩 429 / 500 / 超时 / 坏 JSON;只有 429 触发退避;退避成功后 score 与从未失败路径相同。`rectification-v9-stream.test.ts` 重算失败正文含「这次没有重新比较」、不含「收窄」。 +- 防复发:任何调用 Python 引擎的客户端都不得把非 2xx 压平成单一错误码;429 必须单独成档。引擎调用不得用 `catch {}` 吞掉原因,失败必须留服务端日志且用户文案按原因分档。 +- 相关记录:BUG-715 +- 复发自:BUG-715(那一单的范围写死在星盘页三个文件,防复发没有扫到 `engine-client.ts`) +- 修复版本:待发布 + +## BUG-724 | 两次模型尝试总预算大于路由 maxDuration,重试时无错误码断流 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-16 +- 影响面:`runV9AgentTurn`、`RECTIFICATION_RUN_BUDGET_MS`、agent / regenerate 路由 `maxDuration` +- 用户现象:发生一次可重试失败(`empty_stream`、`evidence_not_written`)后,用户等三五分钟拿到一个连错误码都没有的断流。 +- 触发条件:单次 attempt 210s × 2 = 420s,路由 `maxDuration` 240s。 +- 根因:BUG-388 只约束「单次 attempt < maxDuration」,没约束两次尝试总预算。`RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000` 还在 `agent-run.ts` 与 `rectification-activity-labels.ts` 各写一遍。 +- 修复:引入整轮 `RECTIFICATION_RUN_BUDGET_MS=225_000`(小于 240s)。单次超时取 `min(210s, 剩余预算)`。重试还要 `剩余预算 ≥ RECTIFICATION_MIN_RETRY_ATTEMPT_MS`(20s),否则走 host fallback 给出可见正文,不启动注定被砍的第二次尝试。210_000 只在 `rectification-run-budget.ts` 定义一处。不把 attempt 砍到 110s,不把 `maxDuration` 提到 430s。已盖戳 `open_question` 的超时轮保持现状 fail-closed(见既有 stream 测试),不把题干贴进超时回复。 +- 验证:`RECTIFICATION_RUN_BUDGET_MS < maxDuration`,`maxDuration` 从 agent 与 regenerate 路由源码读取。第一次尝试耗尽预算后返回 retryable,不得发起第二次尝试,必须 host fallback。源码合同:`src/` 里只有一处 `210_000`。 +- 防复发:两次模型尝试的总预算必须由测试断言钉死为小于路由 `maxDuration`,不能只约束单次 attempt。attempt 超时常量只能有一处定义。 +- 相关记录:BUG-059、BUG-388 +- 复发自:BUG-059(总预算约束);BUG-388 的防复发只写了单次尝试,因此没拦住 +- 修复版本:待发布 + diff --git a/docs/tasks/PROGRESS-rectification-failure-attribution-20260915.md b/docs/tasks/PROGRESS-rectification-failure-attribution-20260915.md new file mode 100644 index 00000000..23afba7b --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-failure-attribution-20260915.md @@ -0,0 +1,65 @@ +# PROGRESS · 三处把系统故障说成别的东西(2026-09-15 / 2026-09-16) + +工作树:`.worktrees/rectification-failure-attribution-20260915` +分支:`codex/rectification-failure-attribution-20260915` +任务书基线:`6b3248bf`(已被后续合入);代码基线 `origin/staging` @ `a8d29d1b` +本机 Windows。 + +Skill **未 bump**。未改 `frontend/src/app/page.tsx`、计费口径、分类所用模型、并发闸门、`docs/tasks/README.md` 状态列。未 push `HEAD:staging`。 + +## 开工基线 + +| 项 | 数字 | +| --- | --- | +| `frontend/src/app/page.tsx` | **1951** 行(未改) | +| 最大 BUG 号 | **BUG-720**(本单占用 **722 / 723 / 724**;721 留给 engine-memoization) | +| `tsc --noEmit` | 收尾 0 错 | +| `npm run lint` | 0 error(全仓既有 warning 119;本单未新增 error) | + +`frontend/node_modules` 已存在,未新建 junction。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 6.1 分类器 unavailable vs unclear | 完成 | `outcome` 三档;点选题与 discriminator 不再合并 null / unclear;采集题 `unclassified` 未改 | +| 6.3 整轮超时预算 | 完成 | `RECTIFICATION_RUN_BUDGET_MS=225s < maxDuration 240s`;不足最小重试时长走 host fallback | +| 6.2 引擎分档 + 可见「这次没有重算」 | 完成 | `busy/http_error/timeout/bad_payload`;429 有限退避;失败正文含「这次没有重新比较」 | +| 6.4 Bug 历史 | 完成 | BUG-722 / 723 / 724 | + +## 实现要点 + +- `classifyTurnIntentWithRetry` 返回 `outcome`。`classifier_unavailable` 专指两次都没拿到结构化分类(抛异常或解析空)。点选题回「这边没接上,把刚才那句再发一次就行。」焦点保持 active,不写证据、不扣点。 +- `readEngineJson` 对齐星盘页的四档失败。score / block_scan 对 429 按 `Retry-After` 退避,上限 2 次、总退避 6s(具名常量)。`autoRescoreAfterEvidenceChange` 失败打 `rectification_rescore_failed`,projection 带 `error_kind`。主持人用 `withRescoreSkippedNotice` 补「这次没有重新比较」,并剥掉「收窄 / 范围从」进度句。 +- 整轮 deadline 225s。第二次尝试只在剩余 ≥ 20s 时启动。`210_000` 只定义在 `rectification-run-budget.ts`,`activity-labels` 与 `agent-run` 从那里 import。 +- `agent-run` 不再顶层 import `@/mastra/agentic-rectification`(该模块会在加载时创建 skill symlink)。步进预算抽到 `step-budget.ts`,避免 Windows 无 symlink 权限时 stream 测试整文件无法加载。`agentic-rectification.ts` 再导出,既有 import 路径不变。 + +## 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `rectification-answer-choice.test.ts` 超时合同 | `agent-run` 内 `= 210_000`,只断言 210s < 240s | `210_000` 只在 `rectification-run-budget.ts`;整轮 225s < 两条路由读出的 `maxDuration` | BUG-724 总预算必须小于路由墙 | +| `rectification-window-cluster-cap-20260909.test.ts` 分类调用 | `await classifyRectificationTurnIntent` | `await classifyTurnIntentWithRetry` | BUG-722 discriminator 与点选题同一入口 | +| `rectification-collect-stall.test.ts` 点选题切片 | `classified.intent ===` | `classified?.intent ===` | BUG-722 classified 在 unavailable 时为 null | +| `rectification-v9-status-security.test.ts` stale policy 重算失败形状 | 原始 Error 文案 `stale policy must rescore` | `RectificationEngineError` / `http_error`,且确实打了 `/score` | BUG-723 抛错不得把上游原文泄漏给工具层 | + +未弱化其它既有断言。`timeout after a stamped open_question fails closed` 保持现状(不把题干贴进超时回复)。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `tsc --noEmit` | **0 错** | +| `npm run lint` | **0 error / 119 warning**(与既有全仓 warning 同量级) | +| 任务书所列 tsx 测试(除 `rectification-v9-agent.test.ts`) | 定向套件绿:classifier / stream(50) / answer-choice / spoken-collect / unwritten-evidence / chart-view-engine / engine-contract / voice-copy / window-cluster / eight-method | +| `rectification-v9-agent.test.ts` | **环境缺口**:模块加载 `agentic-rectification` 时 `symlinkSync(..., "dir")` EPERM。本机 Node 只能建 junction。该失败在改本单之前就存在,不是本单回归。 | +| `page.tsx` | 仍 1951 行,未改 | +| 全量 `npm test` | `# tests 3189 / # pass 3104 / # fail 71 / # skipped 14`(修 collect-stall / status-security 两处源码合同后,这两条已绿)。失败集中在无 Docker 的 database-*、部署/Caddy/shell、Windows symlink EPERM(`rectification-v9-agent.test.ts`、`api-service-unavailable` 动态 import 路由、skill-binding)。不含本单定向套件。 | +| `npx next build --webpack` | compile + TypeScript 过;Collecting page data 死在既有 `SkillPackageRegistryError` EPERM symlink(`/api/daily-starlanguage`),与本单无关。Turbopack 因 `frontend/node_modules` junction 指向主仓而直接 panic。未能本机核对 `/` 是否 `○ Static`。 | + +## 环境缺口 + +- 无 Docker:数据库套件红,不伪装成通过。 +- 无登录态、无 Chrome:浏览器级验收不在本单。 +- Windows 无 symlink 权限:`symlinkSync(..., "dir")` EPERM。凡顶层加载 `getRectificationV9Agent` / `RECTIFICATION_V9_SKILL_PATH` 的测试或 `next build` 收集路由都会红。本机 Node 只能建 junction。stream / unwritten-evidence 因 runner 不再顶层加载该模块而可跑。 +- `next build`:webpack 编译过、tsc 过;收集页面数据时 skill runtime symlink EPERM。不伪装成 `/` ○ Static 已核过。 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index ecfb88ab..43902b2e 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -35,7 +35,6 @@ import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectif import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; import { CHOICE_STOP_LABEL, parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card"; import { - classifyRectificationTurnIntent, optionIdForAnswerClass, collectFocusCloseStatus, shouldContinueAgentForDatedEvent, @@ -459,7 +458,16 @@ export async function POST(request: Request) { const classified = intent.classified; expectedWrite = intent.expectedWrite; writeClassified = true; - if (!classified || classified.intent === "unclear") { + if (intent.outcome === "classifier_unavailable") { + const narration = RECTIFICATION_USER_COPY.classifierUnavailableReply; + const turn = await persistV9DeterministicTurn(accounting, userId, caseId, { + requestId, + userMessage: parsed.data.message ?? null, + assistantMessage: narration, + }); + return completedMessageResponse(narration, requestId, caseId, turn.turnId); + } + if (intent.outcome === "unclear") { const narration = RECTIFICATION_USER_COPY.unclearFocusReply; const turn = await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, @@ -468,7 +476,7 @@ export async function POST(request: Request) { }); return completedMessageResponse(narration, requestId, caseId, turn.turnId); } - if (classified.intent === "answer_current_focus") { + if (classified?.intent === "answer_current_focus") { if (!classified.answer_class) { return completedMessageResponse(RECTIFICATION_USER_COPY.questionUpdated, requestId, caseId); } @@ -499,7 +507,7 @@ export async function POST(request: Request) { return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId); } } - if (classified.intent === "stop_rectification" || classified.intent === "ask_about_result") { + if (classified?.intent === "stop_rectification" || classified?.intent === "ask_about_result") { const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const applied = await applyRectificationChoice(accounting, { userId, @@ -631,16 +639,21 @@ export async function POST(request: Request) { }); const open = openQuestionFromPersistedFocus(persisted); if (open && persisted.focus && isRenderableChoiceOpenQuestion(open)) { - let classified = null; - try { - classified = await classifyRectificationTurnIntent(selectedModel, { - focus: persisted.focus, - userMessage: parsed.data.message ?? "", - caseStatus, - signal: request.signal, + const intent = await classifyTurnIntentWithRetry(resolvedModel, { + focus: persisted.focus, + userMessage: parsed.data.message ?? "", + caseStatus, + signal: request.signal, + }); + const classified = intent.classified; + if (intent.outcome === "classifier_unavailable") { + const narration = RECTIFICATION_USER_COPY.classifierUnavailableReply; + const turn = await persistV9DeterministicTurn(accounting, userId, caseId, { + requestId, + userMessage: parsed.data.message ?? null, + assistantMessage: narration, }); - } catch { - classified = null; + return completedMessageResponse(narration, requestId, caseId, turn.turnId); } if (classified?.intent === "answer_current_focus" && classified.answer_class) { const optionId = optionIdForAnswerClass(persisted.focus, classified.answer_class); diff --git a/frontend/src/lib/rectification-activity-labels.ts b/frontend/src/lib/rectification-activity-labels.ts index d3e77836..6a353325 100644 --- a/frontend/src/lib/rectification-activity-labels.ts +++ b/frontend/src/lib/rectification-activity-labels.ts @@ -6,6 +6,9 @@ import type { PublicRectificationActivity, PublicRectificationTool, } from "./rectification-agentic/v9/public-receipt.ts"; +import { RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS as ATTEMPT_TIMEOUT_MS } from "./rectification-run-budget.ts"; + +export { RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS } from "./rectification-run-budget.ts"; export const RECTIFICATION_TOOL_DONE_LABELS: Readonly> = { "rectification-read-case": "读取校正记录", @@ -37,7 +40,6 @@ export const RECTIFICATION_ACTIVITY_PROGRESS_LABELS: Readonly= RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS) { + if (now - input.runStartedAt >= ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS) { return "即将超时,会自动重试或提示"; } if (now - input.stepStartedAt >= RECTIFICATION_SLOW_STEP_MS) { diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 3937ed81..5102988a 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -170,6 +170,7 @@ export const RECTIFICATION_USER_COPY = { collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY, choicePrompt: "直接点下面的选项就行,打字回答也一样算数。", unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。", + classifierUnavailableReply: "这边没接上,把刚才那句再发一次就行。", declaredWindowLockedReply: "搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。", questionUpdated: "这一问刚换成新的,刷新后再答就行。", adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。", @@ -189,6 +190,7 @@ export const RECTIFICATION_USER_COPY = { noCandidatesGate: "当前还排不出可比较的候选时间。", forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。", compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。", + rescoreSkipped: "这次没有重新比较,稍后再比一次。", lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。", deferredCareerWindow: "下一次事业变动的预测窗口留在采用后的核对阶段。", rangeDeliveryTitle: "目前范围", @@ -326,6 +328,26 @@ export function withCompareFailedRetryNotice(body: string): string { return `${spoken}\n\n${notice}`; } +export function stripRangeProgressClaims(body: string): string { + const spoken = body.trim(); + if (!spoken) return ""; + const kept = spoken + .split(/(?<=[。!?])/) + .map((part) => part.trim()) + .filter((part) => part && !/收窄|范围从/.test(part)); + return kept.join(""); +} + +export function withRescoreSkippedNotice(body: string): string { + const notice = RECTIFICATION_USER_COPY.rescoreSkipped; + const spoken = stripRangeProgressClaims(body); + if (spoken.includes("这次没有重新比较")) return spoken; + if (!spoken) return `记下了。${notice}`; + const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}。`; + if (spoken.includes("记下了")) return `${prefix}${notice}`; + return `${prefix}记下了。${notice}`; +} + export function withLastSuccessfulCompareNotice(body: string): string { const notice = RECTIFICATION_USER_COPY.lastSuccessfulCompareRange; const spoken = body.trim(); @@ -554,6 +576,7 @@ export function listUserVisibleCopy(): string[] { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY.choicePrompt, RECTIFICATION_USER_COPY.unclearFocusReply, + RECTIFICATION_USER_COPY.classifierUnavailableReply, RECTIFICATION_USER_COPY.declaredWindowLockedReply, RECTIFICATION_USER_COPY.questionUpdated, RECTIFICATION_USER_COPY.adoptCue, @@ -575,6 +598,7 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.forceMinuteAfterSubBlocks, RECTIFICATION_USER_COPY.postAdoptVerifyDone, RECTIFICATION_USER_COPY.compareFailedRetry, + RECTIFICATION_USER_COPY.rescoreSkipped, RECTIFICATION_USER_COPY.lastSuccessfulCompareRange, RECTIFICATION_USER_COPY.deferredCareerWindow, RECTIFICATION_USER_COPY.rangeDeliveryTitle, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index fc9347c2..07831a2d 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -11,7 +11,7 @@ * successful attempt. */ import type { Agent } from "@mastra/core/agent"; -import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification"; +import { type RectificationAgentAction, resolveRectificationStepBudget } from "./step-budget"; import { createV10RunAttempt, finalizeV10RunAttempt, @@ -43,7 +43,16 @@ import { openingSpokenBody, withCompareFailedRetryNotice, withRangeChangedAfterEvidence, + withRescoreSkippedNotice, } from "../user-copy"; +import { + RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS, + RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S, + RECTIFICATION_MIN_RETRY_ATTEMPT_MS, + RECTIFICATION_RUN_BUDGET_MS, + attemptTimeoutForRemainingBudget, + canStartRetryAttempt, +} from "../../rectification-run-budget.ts"; import { stripQuestionSentences, stripVerbalWindowChange, trimSpokenTurnForInterview, composeIdleGapIntoSpoken } from "./collect-prompt"; import { focusSpokenPrompt } from "./turn-question"; import { previousInferenceFromReceipt } from "../core/compose-receipt.ts"; @@ -62,6 +71,7 @@ import { import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic"; import { batchResultFromToolChunk, + batchRescoreFailed, composeHostFallbackNarration, lastCompletedPublicTool, publicWriteToolCompleted, @@ -116,12 +126,18 @@ export type V9AgentRunOptions = Readonly<{ timeContext?: string; generationModel?: unknown; attemptTimeoutMs?: number; + runBudgetMs?: number; + minRetryAttemptMs?: number; expectedWrite?: "evidence" | "none" | "unknown"; collectIntent?: "classified" | "unclassified" | null; }>; -export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000; -export const RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S = 240; +export { + RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS, + RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S, + RECTIFICATION_MIN_RETRY_ATTEMPT_MS, + RECTIFICATION_RUN_BUDGET_MS, +}; export type V9AgentRunResult = Readonly<{ ok: boolean; @@ -445,11 +461,17 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise 1 && !canStartRetryAttempt(remainingMs, minRetryAttemptMs)) break; const claim = await createV10RunAttempt( accounting, userId, @@ -716,7 +738,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise { timedOut = true; abortController.abort(); - }, options.attemptTimeoutMs ?? RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS); + }, attemptTimeoutForRemainingBudget(runDeadlineAt - Date.now(), attemptCapMs)); let skillBound = true; let caseLoaded = false; @@ -994,7 +1016,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise, + ) { super(message); this.name = "RectificationEngineError"; this.code = code; + this.kind = extras?.kind; + this.httpStatus = extras?.httpStatus; + this.retryAfterSeconds = extras?.retryAfterSeconds; + this.elapsedMs = extras?.elapsedMs; + this.path = extras?.path; + this.errorName = extras?.errorName; } } +export function engineFailureKind(error: unknown): EngineCallFailureKind | null { + if (error instanceof RectificationEngineError && error.kind) return error.kind; + if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) { + return "timeout"; + } + if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) { + return "timeout"; + } + return null; +} + +export function parseEngineRetryAfterSeconds(value: string | null | undefined): number | null { + if (!value) return null; + const trimmed = value.trim(); + if (/^\d+(?:\.\d+)?$/.test(trimmed)) { + const seconds = Number(trimmed); + return Number.isFinite(seconds) && seconds >= 0 ? seconds : null; + } + const at = Date.parse(trimmed); + if (!Number.isFinite(at)) return null; + const seconds = (at - Date.now()) / 1000; + return seconds > 0 ? seconds : 0; +} + +export function nextEngineBusyBackoffMs(input: { + retryAfterSeconds?: number | null; + retriesUsed: number; + backoffUsedMs: number; +}): number | null { + if (input.retriesUsed >= RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT) return null; + const remaining = RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS - input.backoffUsedMs; + if (remaining < 0) return null; + const requested = Math.max(0, Math.round((input.retryAfterSeconds ?? 1) * 1000)); + if (requested === 0) return 0; + if (remaining <= 0) return null; + return Math.min(requested, remaining); +} + +const defaultEngineBusyWait = (ms: number) => new Promise((resolve) => { + setTimeout(resolve, ms); +}); + +let engineBusyWait = defaultEngineBusyWait; + +export function setEngineBusyWaitForTests(wait: ((ms: number) => Promise) | null): void { + engineBusyWait = wait ?? defaultEngineBusyWait; +} + +function logEngineCallFailure(input: { + path: string; + kind: EngineCallFailureKind; + elapsedMs: number; + httpStatus?: number; + errorName?: string; +}): void { + console.warn("rectification_engine_failed", { + path: input.path, + status: input.kind, + httpStatus: input.httpStatus ?? null, + errorName: input.errorName ?? null, + elapsedMs: input.elapsedMs, + }); +} + +function persistenceCodeForKind(kind: EngineCallFailureKind): string { + if (kind === "timeout") return "timeout"; + if (kind === "bad_payload") return "engine_invalid_response"; + return "engine_request_failed"; +} + export type V9EngineEvent = Readonly<{ id: string; domain: string; @@ -186,30 +284,101 @@ function engineBase(): string { return process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"; } -async function readEngineJson(path: string, init: RequestInit, timeoutMs: number): Promise> { - const response = await fetch(`${engineBase()}${path}`, { - ...init, - signal: AbortSignal.timeout(timeoutMs), - }); - const data = await response.json().catch(() => null); - if (!response.ok) { - const message = data && typeof data === "object" - ? String((data as Record).error ?? (data as Record).message ?? `Jyotish API ${response.status}`) - : `Jyotish API ${response.status}`; - throw new RectificationEngineError("engine_request_failed", message); - } - if (!data || typeof data !== "object") { - throw new RectificationEngineError("engine_invalid_response", "Jyotish API returned an invalid response"); - } - return data as Record; +function retryAfterFromHeaders(headers: { get?(name: string): string | null } | undefined): number | null { + if (!headers || typeof headers.get !== "function") return null; + return parseEngineRetryAfterSeconds(headers.get("retry-after") ?? headers.get("Retry-After")); } -async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Promise> { - return readEngineJson(path, { +async function readEngineJson(path: string, init: RequestInit, timeoutMs: number): Promise> { + const started = Date.now(); + try { + const response = await fetch(`${engineBase()}${path}`, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }); + const elapsedMs = Date.now() - started; + const data = await response.json().catch(() => null); + if (!response.ok) { + const kind: EngineCallFailureKind = response.status === 429 ? "busy" : "http_error"; + logEngineCallFailure({ + path, + kind, + elapsedMs, + httpStatus: response.status, + }); + const message = data && typeof data === "object" + ? String((data as Record).error ?? (data as Record).message ?? `Jyotish API ${response.status}`) + : `Jyotish API ${response.status}`; + throw new RectificationEngineError(persistenceCodeForKind(kind), message, { + kind, + httpStatus: response.status, + retryAfterSeconds: retryAfterFromHeaders(response.headers) ?? undefined, + elapsedMs, + path, + }); + } + if (!data || typeof data !== "object") { + logEngineCallFailure({ + path, + kind: "bad_payload", + elapsedMs, + httpStatus: response.status, + }); + throw new RectificationEngineError("engine_invalid_response", "Jyotish API returned an invalid response", { + kind: "bad_payload", + httpStatus: response.status, + elapsedMs, + path, + }); + } + return data as Record; + } catch (error) { + if (error instanceof RectificationEngineError) throw error; + const elapsedMs = Date.now() - started; + const errorName = error instanceof Error ? error.name : "Error"; + const kind: EngineCallFailureKind = errorName === "TimeoutError" || errorName === "AbortError" + ? "timeout" + : "http_error"; + logEngineCallFailure({ path, kind, elapsedMs, errorName }); + throw new RectificationEngineError(persistenceCodeForKind(kind), "Jyotish API request failed", { + kind, + elapsedMs, + path, + errorName, + }); + } +} + +async function postEngine( + path: string, + body: unknown, + timeoutMs = 60_000, + options?: Readonly<{ busyRetry?: boolean }>, +): Promise> { + const init: RequestInit = { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), - }, timeoutMs); + }; + if (!options?.busyRetry) return readEngineJson(path, init, timeoutMs); + let retriesUsed = 0; + let backoffUsedMs = 0; + while (true) { + try { + return await readEngineJson(path, init, timeoutMs); + } catch (error) { + if (!(error instanceof RectificationEngineError) || error.kind !== "busy") throw error; + const waitMs = nextEngineBusyBackoffMs({ + retryAfterSeconds: error.retryAfterSeconds, + retriesUsed, + backoffUsedMs, + }); + if (waitMs == null) throw error; + if (waitMs > 0) await engineBusyWait(waitMs); + retriesUsed += 1; + backoffUsedMs += waitMs; + } + } } async function getEngine(path: string, timeoutMs = 5_000): Promise> { @@ -645,13 +814,16 @@ export async function runV9VedastroValidate(input: { raw: data, }; } catch (error) { - const code = error instanceof RectificationEngineError - ? error.code === "engine_invalid_response" + const kind = engineFailureKind(error); + const code = kind === "timeout" || (error instanceof RectificationEngineError && error.code === "timeout") + ? "timeout" + : error instanceof RectificationEngineError && (error.kind === "bad_payload" || error.code === "engine_invalid_response") ? "engine_invalid_response" - : "engine_request_failed" - : error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError") - ? "timeout" - : "unknown"; + : error instanceof RectificationEngineError + ? "engine_request_failed" + : error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError") + ? "timeout" + : "unknown"; return { status: "failed", canConfirmExactMinute: false, @@ -671,7 +843,7 @@ export async function runV9CandidateScore(input: { columnTimes?: readonly string[]; refreshProbes?: boolean; }): Promise { - const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input)); + const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input), 60_000, { busyRetry: true }); const candidates = readCandidates(data.candidate_decisions, input.candidateRange); const receipt = readDecisionReceipt(data.decision_receipt, candidates); const ledger = readExecutionLedger(data.execution_ledger_version, data.execution_ledger); @@ -826,7 +998,7 @@ export async function runV9BlockScan(input: { ...engineRequestBody(input), minute_step: input.minuteStep ?? 10, ...(input.blocks?.length ? { blocks: input.blocks } : {}), - }); + }, 60_000, { busyRetry: true }); const parsed = parseBlockScanPayload({ blocks: data.blocks, algorithm_version: data.algorithm_version, diff --git a/frontend/src/lib/rectification-agentic/v9/host-fallback.ts b/frontend/src/lib/rectification-agentic/v9/host-fallback.ts index 53ce644b..4180b255 100644 --- a/frontend/src/lib/rectification-agentic/v9/host-fallback.ts +++ b/frontend/src/lib/rectification-agentic/v9/host-fallback.ts @@ -115,6 +115,12 @@ export function batchResultFromToolChunk(chunk: { return result; } +export function batchRescoreFailed(batchResult: unknown): boolean { + if (!batchResult || typeof batchResult !== "object" || Array.isArray(batchResult)) return false; + const rescore = (batchResult as { rescore?: { status?: unknown } }).rescore; + return rescore?.status === "failed"; +} + export function composeHostFallbackNarration(batchResult: unknown): string | null { if (batchResult == null) return null; if (isToolInputRejection(batchResult)) return null; diff --git a/frontend/src/lib/rectification-agentic/v9/step-budget.ts b/frontend/src/lib/rectification-agentic/v9/step-budget.ts new file mode 100644 index 00000000..e7ca7efe --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/step-budget.ts @@ -0,0 +1,28 @@ +/** + * Bounded step budgets for a rectification Agent turn. + * Kept off the Mastra agent module so the runner can load without creating + * a runtime skill symlink. + */ +export type RectificationAgentAction = + | "opening" + | "read_only" + | "evidence" + | "rescore" + | "accept" + | "confirm"; + +export const RECTIFICATION_AGENT_STEP_BUDGETS: Readonly> = { + opening: 6, + read_only: 6, + evidence: 8, + rescore: 12, + accept: 6, + confirm: 6, +}; + +export const RECTIFICATION_AGENT_MAX_STEPS = 12; +export const RECTIFICATION_AGENT_HARD_STEP_LIMIT = 16; + +export function resolveRectificationStepBudget(action: RectificationAgentAction): number { + return Math.min(RECTIFICATION_AGENT_HARD_STEP_LIMIT, RECTIFICATION_AGENT_STEP_BUDGETS[action]); +} diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 8c20e53b..c3f13c79 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -2057,6 +2057,7 @@ export function safeToolErrorCode(error: unknown): string { "engine_invalid_response", "engine_profile_incomplete", "no_scorable_evidence", + "timeout", ]; if (error instanceof Error && error.name === "RectificationEngineError") { const code = "code" in error && typeof error.code === "string" ? error.code : ""; diff --git a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts index 2d555dc3..4a3b3c0f 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts @@ -53,6 +53,14 @@ export function shouldContinueAgentForDatedEvent( export type ExpectedWriteSignal = "evidence" | "none" | "unknown"; +export type TurnIntentClassifierOutcome = "classified" | "unclear" | "classifier_unavailable"; + +export type TurnIntentClassifierResult = Readonly<{ + classified: RectificationTurnIntent | null; + expectedWrite: ExpectedWriteSignal; + outcome: TurnIntentClassifierOutcome; +}>; + export function expectedWriteFromCollectIntent( classified: RectificationTurnIntent | null, focus?: ConversationFocus | null, @@ -72,6 +80,15 @@ export function expectedWriteFromCollectIntent( return "none"; } +export function turnIntentOutcome( + classified: RectificationTurnIntent | null, +): TurnIntentClassifierOutcome { + if (!classified) return "classifier_unavailable"; + return classified.intent === "unclear" ? "unclear" : "classified"; +} + +type ClassifyTurnIntent = typeof classifyRectificationTurnIntent; + export async function classifyTurnIntentWithRetry( model: ResolvedLanguageModel, input: { @@ -80,21 +97,28 @@ export async function classifyTurnIntentWithRetry( caseStatus: string; signal?: AbortSignal; }, -): Promise<{ classified: RectificationTurnIntent | null; expectedWrite: ExpectedWriteSignal }> { + classify: ClassifyTurnIntent = classifyRectificationTurnIntent, +): Promise { + const started = Date.now(); for (let attempt = 0; attempt < 2; attempt += 1) { try { - const classified = await classifyRectificationTurnIntent(model, input); + const classified = await classify(model, input); if (classified) { return { classified, expectedWrite: expectedWriteFromCollectIntent(classified, input.focus), + outcome: turnIntentOutcome(classified), }; } } catch { - // One retry, then fail-open as unknown. + // One retry, then fail-open as unknown / classifier_unavailable. } } - return { classified: null, expectedWrite: "unknown" }; + console.warn("rectification_classifier_unavailable", { + elapsedMs: Date.now() - started, + attempts: 2, + }); + return { classified: null, expectedWrite: "unknown", outcome: "classifier_unavailable" }; } export function optionIdForAnswerClass( diff --git a/frontend/src/lib/rectification-run-budget.ts b/frontend/src/lib/rectification-run-budget.ts new file mode 100644 index 00000000..03a57472 --- /dev/null +++ b/frontend/src/lib/rectification-run-budget.ts @@ -0,0 +1,27 @@ +/** + * Single definition of rectification Agent wall-clock budgets. + * + * Attempt cap stays at 210s (BUG-388). The whole-run budget is strictly less + * than the route `maxDuration` so a retry cannot start a second attempt that + * the edge will cut (BUG-059 / BUG-724). + */ +export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000; +export const RECTIFICATION_RUN_BUDGET_MS = 225_000; +export const RECTIFICATION_MIN_RETRY_ATTEMPT_MS = 20_000; +export const RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S = 240; + +export function attemptTimeoutForRemainingBudget( + remainingMs: number, + attemptCapMs = RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS, +): number { + if (!Number.isFinite(remainingMs) || remainingMs <= 0) return 0; + if (!Number.isFinite(attemptCapMs) || attemptCapMs <= 0) return 0; + return Math.min(attemptCapMs, remainingMs); +} + +export function canStartRetryAttempt( + remainingMs: number, + minAttemptMs = RECTIFICATION_MIN_RETRY_ATTEMPT_MS, +): boolean { + return Number.isFinite(remainingMs) && remainingMs >= minAttemptMs; +} diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 36287f83..0440aa57 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -21,37 +21,13 @@ export const RECTIFICATION_V9_PACKAGE_PATH = activeRectificationSkill.resolvedPa /** The basename-correct alias that is safe to pass to Mastra WorkspaceSkills. */ export const RECTIFICATION_V9_SKILL_PATH = resolveSkillPackageRuntimePath(activeRectificationSkill); -/** - * V9 rectification agent actions. The action drives the bounded step budget; - * the server never lets the model run an unbounded loop. - */ -export type RectificationAgentAction = - | "opening" - | "read_only" - | "evidence" - | "rescore" - | "accept" - | "confirm"; - -/** - * Bounded step budgets per action (plan §6.4). The hard ceiling is enforced - * by min() so a misbehaving action can never exceed the global cap. - */ -export const RECTIFICATION_AGENT_STEP_BUDGETS: Readonly> = { - opening: 6, - read_only: 6, - evidence: 8, - rescore: 12, - accept: 6, - confirm: 6, -}; - -export const RECTIFICATION_AGENT_MAX_STEPS = 12; -export const RECTIFICATION_AGENT_HARD_STEP_LIMIT = 16; - -export function resolveRectificationStepBudget(action: RectificationAgentAction): number { - return Math.min(RECTIFICATION_AGENT_HARD_STEP_LIMIT, RECTIFICATION_AGENT_STEP_BUDGETS[action]); -} +export { + RECTIFICATION_AGENT_HARD_STEP_LIMIT, + RECTIFICATION_AGENT_MAX_STEPS, + RECTIFICATION_AGENT_STEP_BUDGETS, + resolveRectificationStepBudget, + type RectificationAgentAction, +} from "../lib/rectification-agentic/v9/step-budget.ts"; /** * System prompt: high-priority behavioral and truth boundaries in Simplified diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index f708b9e6..f7bed016 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -163,6 +163,7 @@ import { executedMethodsFromLedger, runV9RangeReading, runV9BlockScan, + engineFailureKind, type V9EngineScoreResult, } from "@/lib/rectification-agentic/v9/engine-client"; @@ -1028,11 +1029,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { try { const dossier = await loadV9CaseDossier(accounting, userId, targetCaseId); if (isTerminalStatus(dossier.case.status as RectificationCaseStatus)) { - return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null }; + return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null }; } const parsed = parseDossierForTools(dossier); if (parsed.scorable.length === 0 || !parsed.case.candidateRange) { - return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null }; + return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null }; } const fingerprint = evidenceLedgerFingerprint(dossier.evidence); if (parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) { @@ -1043,6 +1044,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { status: "skipped" as const, executedMethods: [] as const, errorCode: null, + errorKind: null, cached: true, openQuestion: persisted ? openQuestionFromPersistedFocus(persisted.persistedFocus) @@ -1068,14 +1070,24 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { status: "completed" as const, executedMethods: scored.score.executedMethods, errorCode: null, + errorKind: null, cached: scored.persisted.cached, openQuestion: openQuestionFromPersistedFocus(persisted.persistedFocus), }; } catch (error) { + const errorKind = engineFailureKind(error); + console.warn("rectification_rescore_failed", { + status: errorKind ?? "http_error", + errorCode: safeToolErrorCode(error), + elapsedMs: error instanceof Error && "elapsedMs" in error && typeof error.elapsedMs === "number" + ? error.elapsedMs + : null, + }); return { status: "failed" as const, executedMethods: [] as const, errorCode: safeToolErrorCode(error), + errorKind, cached: false, openQuestion: null, }; @@ -1637,7 +1649,7 @@ 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 }; + : { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null }; const acceptedRecaps = writeItems.flatMap((item, offset) => { const recorded = result.items[offset]; if (!recorded || recorded.outcome !== "accepted") return []; @@ -1675,6 +1687,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { status: rescore.status, executed_methods: rescore.executedMethods, error_code: rescore.errorCode, + error_kind: rescore.errorKind, }, open_question: rescore.openQuestion, }; @@ -1813,7 +1826,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { } const rescore = result.status === "confirmed" ? await autoRescoreAfterEvidenceChange(input.caseId) - : { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null }; + : { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null }; const projection = { evidence_id: result.evidenceId, status: result.status, @@ -1822,6 +1835,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { status: rescore.status, executed_methods: rescore.executedMethods, error_code: rescore.errorCode, + error_kind: rescore.errorKind, }, open_question: rescore.openQuestion, }; diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index 66c88bd9..747b7515 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -16,6 +16,7 @@ import { nonConvergingRangeNarration, openingSpokenBody, stopReasonPrefix, + withRescoreSkippedNotice, } from "../src/lib/rectification-agentic/user-copy.ts"; import { COLLECT_FLOW_BANNED_PHRASES, SPLIT_ENDPOINT_PHRASE } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts"; @@ -229,6 +230,15 @@ test("settled assistant body with a focus has no question-mark sentences", () => assert.doesNotMatch(linked[0]?.text ?? "", /[??]/); assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff)); assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.evidenceNotRecorded)); + assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.classifierUnavailableReply)); + assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rescoreSkipped)); + assert.notEqual( + RECTIFICATION_USER_COPY.classifierUnavailableReply, + RECTIFICATION_USER_COPY.unclearFocusReply, + ); + assert.match(RECTIFICATION_USER_COPY.rescoreSkipped, /这次没有重新比较/); + assert.match(withRescoreSkippedNotice("记下了:2016 年 3 月入学。范围在收窄。"), /这次没有重新比较/); + assert.doesNotMatch(withRescoreSkippedNotice("记下了:2016 年 3 月入学。范围在收窄。"), /收窄/); assert.doesNotMatch(RECTIFICATION_USER_COPY.evidenceNotRecorded, /系统|模型/); assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/); const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index c8323451..434e2ecb 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -1463,14 +1463,24 @@ test("the public agent route treats structured choice as a non-model command", ( test("rectification attempt timeout stays under the agent route budget", () => { const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); + const budget = readFileSync(new URL("../src/lib/rectification-run-budget.ts", import.meta.url), "utf8"); const regenerate = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", import.meta.url), "utf8", ); - assert.match(agentRun, /RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000/); - assert.match(agentRun, /RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S = 240/); - assert.match(regenerate, /export const maxDuration = 240/); - assert.ok(210_000 < 240_000); + const agentRoute = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + // 原值: agent-run 内 RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000,只断言单次 < 240s + // 新值: 210_000 只定义一次;整轮 RECTIFICATION_RUN_BUDGET_MS 从路由模块读取 maxDuration 并必须更小 + // 原因: BUG-724 复发自 BUG-059,两次尝试总预算必须显式小于路由 maxDuration + assert.match(budget, /export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000/); + assert.match(budget, /export const RECTIFICATION_RUN_BUDGET_MS = 225_000/); + assert.doesNotMatch(agentRun, /210_000/); + const agentMax = Number(agentRoute.match(/export const maxDuration = (\d+)/)?.[1]); + const regenerateMax = Number(regenerate.match(/export const maxDuration = (\d+)/)?.[1]); + assert.equal(agentMax, 240); + assert.equal(regenerateMax, 240); + assert.ok(225_000 < agentMax * 1000); + assert.ok(225_000 < regenerateMax * 1000); assert.match(agentRun, /RETRYABLE_ERROR_CODES = new Set\(\[/); const retryable = agentRun.slice( agentRun.indexOf("const RETRYABLE_ERROR_CODES"), diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index add5d2a8..29984b47 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -1340,9 +1340,12 @@ test("has_new_dated_event continues into the agent after applying the answer", ( assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/); assert.match(fastPath, /shouldContinueAgentForDatedEvent/); assert.match(fastPath, /deferFollowup:\s*continueToAgent/); + // 原值: if (classified.intent === "answer_current_focus") + // 新值: if (classified?.intent === "answer_current_focus") + // 原因: BUG-722 分类器 unavailable 时 classified 可能为 null,不得再当用户没说清 const choiceApply = fastPath.slice( - fastPath.indexOf("if (classified.intent === \"answer_current_focus\")"), - fastPath.indexOf("if (classified.intent === \"stop_rectification\")"), + fastPath.indexOf("if (classified?.intent === \"answer_current_focus\")"), + fastPath.indexOf("if (classified?.intent === \"stop_rectification\""), ); assert.ok(choiceApply.indexOf("applyRectificationChoice") < choiceApply.indexOf("if (!continueToAgent)")); assert.match(choiceApply, /return completedMessageResponse\(applied\.narration/); diff --git a/frontend/tests/rectification-turn-intent-classifier.test.ts b/frontend/tests/rectification-turn-intent-classifier.test.ts index f94f90d7..e379e911 100644 --- a/frontend/tests/rectification-turn-intent-classifier.test.ts +++ b/frontend/tests/rectification-turn-intent-classifier.test.ts @@ -9,7 +9,11 @@ import { shouldDeclineCollectFocus, collectFocusCloseStatus, expectedWriteFromCollectIntent, + classifyTurnIntentWithRetry, + turnIntentOutcome, } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts"; +import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; +import type { ResolvedLanguageModel } from "../src/mastra/model.ts"; import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, FOCUS_ID } from "./rectification-v9-test-support.ts"; @@ -227,5 +231,72 @@ test("classifyTurnIntentWithRetry fails open as unknown after two misses", () => assert.match(src, /export async function classifyTurnIntentWithRetry/); assert.match(src, /for \(let attempt = 0; attempt < 2/); assert.match(src, /expectedWrite: "unknown"/); + assert.match(src, /outcome: "classifier_unavailable"/); assert.doesNotMatch(src, /\(\?:19\|20\)\\d\{2\}/); }); + +const dummyModel = { id: "test-model" } as ResolvedLanguageModel; + +test("two classifier throws are classifier_unavailable, not unclear", async () => { + let calls = 0; + const warnings: unknown[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + const result = await classifyTurnIntentWithRetry( + dummyModel, + { userMessage: "2016年3月入学", caseStatus: "collecting_evidence" }, + async () => { + calls += 1; + throw Object.assign(new Error("upstream 5xx"), { name: "APICallError" }); + }, + ); + assert.equal(calls, 2); + assert.equal(result.classified, null); + assert.equal(result.expectedWrite, "unknown"); + assert.equal(result.outcome, "classifier_unavailable"); + assert.equal(turnIntentOutcome(null), "classifier_unavailable"); + const line = JSON.stringify(warnings[0]); + assert.match(line, /rectification_classifier_unavailable/); + assert.match(line, /elapsedMs/); + assert.doesNotMatch(line, /2016|入学|userMessage/); + } finally { + console.warn = original; + } +}); + +test("a model unclear intent is outcome unclear and uses a different reply than unavailable", async () => { + const result = await classifyTurnIntentWithRetry( + dummyModel, + { userMessage: "随便吧", caseStatus: "collecting_evidence" }, + async () => ({ intent: "unclear", answer_class: null }), + ); + assert.equal(result.outcome, "unclear"); + assert.equal(result.classified?.intent, "unclear"); + assert.equal(result.expectedWrite, "none"); + assert.equal(turnIntentOutcome({ intent: "unclear", answer_class: null }), "unclear"); + assert.notEqual( + RECTIFICATION_USER_COPY.classifierUnavailableReply, + RECTIFICATION_USER_COPY.unclearFocusReply, + ); + assert.match(RECTIFICATION_USER_COPY.classifierUnavailableReply, /再发一次/); + assert.match(RECTIFICATION_USER_COPY.unclearFocusReply, /不太确定/); +}); + +test("route no longer merges classifier null with user-unclear", () => { + const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + assert.doesNotMatch(route, /!classified \|\| classified\.intent === "unclear"/); + assert.match(route, /outcome === "classifier_unavailable"/); + assert.match(route, /outcome === "unclear"/); + assert.match(route, /classifierUnavailableReply/); + const unavailableAt = route.indexOf('outcome === "classifier_unavailable"'); + const unclearAt = route.indexOf('outcome === "unclear"'); + assert.ok(unavailableAt >= 0 && unclearAt > unavailableAt); + const unavailableBlock = route.slice(unavailableAt, unclearAt); + assert.match(unavailableBlock, /classifierUnavailableReply/); + assert.doesNotMatch(unavailableBlock, /applyRectificationChoice/); + assert.doesNotMatch(unavailableBlock, /authorizeUsage/); + assert.doesNotMatch(unavailableBlock, /record-evidence-batch|recordV10EvidenceBatch/); +}); diff --git a/frontend/tests/rectification-v9-engine-contract.test.ts b/frontend/tests/rectification-v9-engine-contract.test.ts index 49fbac17..098d72d1 100644 --- a/frontend/tests/rectification-v9-engine-contract.test.ts +++ b/frontend/tests/rectification-v9-engine-contract.test.ts @@ -1,14 +1,19 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { RectificationEngineError, + RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS, + RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT, engineRequestBody, mergeVedastroValidateIntoReceipt, + nextEngineBusyBackoffMs, runV9CandidateScore, runV9Diagnostics, runV9VedastroValidate, sanitizeAskedProbeKeysForEngine, + setEngineBusyWaitForTests, toEngineEvents, type V9EngineScoreResult, } from "../src/lib/rectification-agentic/v9/engine-client.ts"; @@ -521,3 +526,189 @@ test("engineRequestBody forwards a slow-compare column_times subset", () => { }); assert.deepEqual(body.column_times, ["14:46", "14:47"]); }); + +const SCORE_INPUT = { + baselineBirthSnapshot: SNAPSHOT, + candidateRange: RANGE, + events: toEngineEvents(EVIDENCE), +}; + +function stubEngineSequence( + responses: Array<{ + status?: number; + body?: unknown; + retryAfter?: string; + throwError?: Error; + }>, +) { + const previous = globalThis.fetch; + let index = 0; + globalThis.fetch = (async () => { + const next = responses[index] ?? responses.at(-1); + index += 1; + if (!next) throw new Error("no stubbed engine response"); + if (next.throwError) throw next.throwError; + const status = next.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(next.retryAfter ? { "Retry-After": next.retryAfter } : {}), + json: async () => next.body, + }; + }) as unknown as typeof fetch; + return { + calls: () => index, + restore() { + globalThis.fetch = previous; + }, + }; +} + +function captureWarnings(run: () => Promise): Promise<{ result: PromiseSettledResult; warnings: unknown[] }> { + const warnings: unknown[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + return run() + .then((value) => ({ result: { status: "fulfilled" as const, value }, warnings })) + .catch((reason: unknown) => ({ result: { status: "rejected" as const, reason }, warnings })) + .finally(() => { + console.warn = original; + }); +} + +test("busy backoff is a named cap of two waits and six seconds", () => { + assert.equal(RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT, 2); + assert.equal(RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS, 6_000); + assert.equal(nextEngineBusyBackoffMs({ + retryAfterSeconds: 2, + retriesUsed: 0, + backoffUsedMs: 0, + }), 2_000); + assert.equal(nextEngineBusyBackoffMs({ + retryAfterSeconds: 2, + retriesUsed: 1, + backoffUsedMs: 2_000, + }), 2_000); + assert.equal(nextEngineBusyBackoffMs({ + retryAfterSeconds: 2, + retriesUsed: 2, + backoffUsedMs: 4_000, + }), null); + assert.equal(nextEngineBusyBackoffMs({ + retryAfterSeconds: 10, + retriesUsed: 0, + backoffUsedMs: 0, + }), 6_000); + assert.equal(nextEngineBusyBackoffMs({ + retryAfterSeconds: 2, + retriesUsed: 1, + backoffUsedMs: 6_000, + }), null); + const client = readFileSync( + new URL("../src/lib/rectification-agentic/v9/engine-client.ts", import.meta.url), + "utf8", + ); + assert.match(client, /RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT = 2/); + assert.match(client, /RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS = 6_000/); + assert.match(client, /retriesUsed >= RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT/); +}); + +test("score classifies 429, 500, timeout, and bad JSON separately and only 429 retries", async () => { + const waits: number[] = []; + setEngineBusyWaitForTests(async (ms) => { + waits.push(ms); + }); + try { + const busyThenOk = stubEngineSequence([ + { status: 429, body: { error: "ERR_COMPUTE_BUSY" }, retryAfter: "2" }, + { status: 200, body: ENGINE_SCORE_RESPONSE_V2 }, + ]); + const busyOk = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT)); + busyThenOk.restore(); + assert.equal(busyOk.result.status, "fulfilled"); + if (busyOk.result.status === "fulfilled") { + assert.equal(busyOk.result.value.representativeTime, "04:50"); + } + assert.equal(waits.length, 1); + assert.equal(waits[0], 2_000); + assert.equal(busyThenOk.calls(), 2); + const busyLog = JSON.stringify(busyOk.warnings[0]); + assert.match(busyLog, /rectification_engine_failed/); + assert.match(busyLog, /"busy"/); + assert.match(busyLog, /"httpStatus":429/); + assert.doesNotMatch(busyLog, /1997-08-08|latitude|token|JWT/); + + waits.length = 0; + const http = stubEngineSequence([{ status: 500, body: { error: "boom" } }]); + const httpResult = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT)); + http.restore(); + assert.equal(httpResult.result.status, "rejected"); + if (httpResult.result.status === "rejected") { + const error = httpResult.result.reason; + assert.ok(error instanceof RectificationEngineError); + assert.equal(error.kind, "http_error"); + assert.equal(error.httpStatus, 500); + } + assert.equal(waits.length, 0); + assert.equal(http.calls(), 1); + assert.match(JSON.stringify(httpResult.warnings[0]), /"http_error"/); + + const timeout = stubEngineSequence([{ + throwError: Object.assign(new Error("aborted"), { name: "TimeoutError" }), + }]); + const timeoutResult = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT)); + timeout.restore(); + assert.equal(timeoutResult.result.status, "rejected"); + if (timeoutResult.result.status === "rejected") { + const error = timeoutResult.result.reason; + assert.ok(error instanceof RectificationEngineError); + assert.equal(error.kind, "timeout"); + } + assert.equal(waits.length, 0); + assert.match(JSON.stringify(timeoutResult.warnings[0]), /"timeout"/); + + const bad = stubEngineSequence([{ status: 200, body: null }]); + const badResult = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT)); + bad.restore(); + assert.equal(badResult.result.status, "rejected"); + if (badResult.result.status === "rejected") { + const error = badResult.result.reason; + assert.ok(error instanceof RectificationEngineError); + assert.equal(error.kind, "bad_payload"); + assert.equal(error.code, "engine_invalid_response"); + } + assert.equal(waits.length, 0); + assert.match(JSON.stringify(badResult.warnings[0]), /"bad_payload"/); + } finally { + setEngineBusyWaitForTests(null); + } +}); + +test("busy retries that still fail keep the busy kind", async () => { + const waits: number[] = []; + setEngineBusyWaitForTests(async (ms) => { + waits.push(ms); + }); + const stub = stubEngineSequence([ + { status: 429, body: { error: "ERR_COMPUTE_BUSY" }, retryAfter: "2" }, + { status: 429, body: { error: "ERR_COMPUTE_BUSY" }, retryAfter: "2" }, + { status: 429, body: { error: "ERR_COMPUTE_BUSY" }, retryAfter: "2" }, + ]); + try { + const result = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT)); + assert.equal(result.result.status, "rejected"); + if (result.result.status === "rejected") { + const error = result.result.reason; + assert.ok(error instanceof RectificationEngineError); + assert.equal(error.kind, "busy"); + } + assert.equal(waits.length, 2); + assert.equal(stub.calls(), 3); + } finally { + stub.restore(); + setEngineBusyWaitForTests(null); + } +}); + diff --git a/frontend/tests/rectification-v9-status-security.test.ts b/frontend/tests/rectification-v9-status-security.test.ts index 816fb830..8f0b6b0d 100644 --- a/frontend/tests/rectification-v9-status-security.test.ts +++ b/frontend/tests/rectification-v9-status-security.test.ts @@ -793,7 +793,14 @@ test("compare-candidates rescores when stored policy lags the live engine", asyn (tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise; }).execute({ caseId: CASE_ID }), - (error: unknown) => error instanceof Error && error.message.includes("stale policy must rescore"), + (error: unknown) => { + // 原值: 原始 Error 文案 "stale policy must rescore" 原样冒出 + // 新值: 引擎客户端把非 2xx / 抛错收成 RectificationEngineError(http_error) + // 原因: BUG-723 不得把失败原因压平或把上游原文泄漏给工具层 + return error instanceof Error + && error.name === "RectificationEngineError" + && scoreRequested; + }, ); assert.equal(scoreRequested, true); } finally { diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index f4209eb8..95b762c1 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { @@ -10,7 +11,15 @@ import { streamToolNames, } from "../src/lib/rectification-agentic/v9/stream-mapping.ts"; import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts"; -import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts"; +import { + RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS, + RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S, + RECTIFICATION_MIN_RETRY_ATTEMPT_MS, + RECTIFICATION_RUN_BUDGET_MS, + runV9AgentTurn, + type V9AgentRunOptions, +} from "../src/lib/rectification-agentic/v9/agent-run.ts"; +import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; import { CASE_ID, SESSION_ID, @@ -1851,3 +1860,85 @@ test("does not create a 2002 user message from a 2020 assistant suggestion", asy assert.doesNotMatch(result.answerText, /2002/); assert.equal(result.answerText, "记下了,2020年4月开始实习。"); }); + +test("whole-run budget is less than both route maxDuration values", () => { + const agentRoute = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + const regenerateRoute = readFileSync( + new URL("../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", import.meta.url), + "utf8", + ); + const agentMax = Number(agentRoute.match(/export const maxDuration = (\d+)/)?.[1]); + const regenerateMax = Number(regenerateRoute.match(/export const maxDuration = (\d+)/)?.[1]); + assert.equal(agentMax, RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S); + assert.equal(regenerateMax, RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S); + assert.ok(RECTIFICATION_RUN_BUDGET_MS < agentMax * 1000); + assert.ok(RECTIFICATION_RUN_BUDGET_MS < regenerateMax * 1000); + assert.ok(RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS <= RECTIFICATION_RUN_BUDGET_MS); + assert.ok(RECTIFICATION_MIN_RETRY_ATTEMPT_MS > 0); + const budget = readFileSync(new URL("../src/lib/rectification-run-budget.ts", import.meta.url), "utf8"); + const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); + const labels = readFileSync(new URL("../src/lib/rectification-activity-labels.ts", import.meta.url), "utf8"); + assert.match(budget, /export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000/); + assert.doesNotMatch(agentRun, /210_000/); + assert.doesNotMatch(labels, /210_000/); +}); + +test("a retryable first attempt that exhausts the run budget does not start a second attempt", async () => { + let buildCount = 0; + const { options, emitted, billing } = runOptions({ + runBudgetMs: 40, + minRetryAttemptMs: 80, + expectedWrite: "evidence", + 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("text-delta", { text: "记下了:2016 年 3 月入学。" }), + chunk("finish"), + ]) as never; + }, + }); + const result = await runV9AgentTurn(options); + assert.equal(buildCount, 1); + assert.equal(result.ok, true); + assert.equal(emitted.some((event) => event.type === "attempt.reset"), false); + assert.ok(result.phases.includes("answer.host_fallback")); + assert.match(result.answerText, /还没记上|再发|现有材料|再说一次/); + assert.equal(emitted.some((event) => event.type === "run.completed"), true); + assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 }); +}); + +test("failed evidence rescore tells the user this turn did not re-compare", async () => { + const { options, emitted } = 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-record-evidence-batch", args: { caseId: CASE_ID } }), + chunk("tool-result", { + toolName: "rectification-record-evidence-batch", + result: { + accepted_count: 1, + accepted_recaps: [{ display_date_label: "2016年3月", event_phrase: "入学" }], + rescore: { status: "failed", error_code: "engine_request_failed", error_kind: "busy" }, + }, + }), + chunk("text-delta", { text: "记下了:2016 年 3 月入学。范围在收窄。" }), + chunk("finish"), + ]) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.match(result.answerText, /这次没有重新比较/); + assert.match(result.answerText, /记下了/); + assert.doesNotMatch(result.answerText, /收窄/); + assert.equal(result.answerText.includes(RECTIFICATION_USER_COPY.rescoreSkipped), true); + const spoken = emitted.filter((event) => event.type === "answer.delta").map((event) => event.text).join(""); + assert.match(spoken, /这次没有重新比较/); +}); diff --git a/frontend/tests/rectification-window-cluster-cap-20260909.test.ts b/frontend/tests/rectification-window-cluster-cap-20260909.test.ts index ef371278..92ccc774 100644 --- a/frontend/tests/rectification-window-cluster-cap-20260909.test.ts +++ b/frontend/tests/rectification-window-cluster-cap-20260909.test.ts @@ -43,7 +43,10 @@ test("declared birth-window utterances parse ranges and around-times", () => { test("choice-focus declared window is answered from the route before the model", () => { const intercept = ROUTE.indexOf('if (action === "message" && parseDeclaredBirthWindow'); const selectedModel = ROUTE.indexOf("const selectedModel = isStructuredChoice"); - const classifyCall = ROUTE.indexOf("await classifyRectificationTurnIntent"); + // 原值: await classifyRectificationTurnIntent + // 新值: await classifyTurnIntentWithRetry(点选题与 discriminator 同一入口) + // 原因: BUG-722 分类器失败不得再走「用户说不清」 + const classifyCall = ROUTE.indexOf("await classifyTurnIntentWithRetry"); assert.ok(intercept > 0); assert.ok(selectedModel > intercept); assert.ok(classifyCall > intercept);