diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc926b5..e782441e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-12 — 记下经历后设置下一问不再连失败;兜底「记下了」不再把日期写两遍 + +记下两件事后,系统已经写下「还有吗」时,助手再设置同一问会直接沿用,不再连失败六次。工具失败会留下可诊断的原因码和打码后的原文,不再只显示笼统失败。模型没写出正文时,兜底「记下了」若经历描述里已有年月,不再把 `2016-09` 再拼一遍。Skill 版本不变。 + + ## 2026-09-12 — 生时校正定向补事能落下;刷新无新题也记住已经问过 带年月选择题问完后,定向 / 锚定 / 通用补事题会按表里允许的经历种类写入,不再因为种类名不合法而静默失败。刷新按还剩下的候选分钟来出题;如果没有新题,会记下「这一轮已经问过引擎」,同一批答案不会再打一次,界面走定向补事或给出目前范围。定向题若写不进库,同一轮会出「目前范围」卡,不会停在「没有拿到下一个问题」。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 29865d23..2c11922c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10244,3 +10244,35 @@ - 相关记录:BUG-655、BUG-651、BUG-652、BUG-656、BUG-657 - 复发自:BUG-655(堵住空 `supersede` 后未留下「已刷新」事实) - 修复版本:待发布 + +## BUG-659 | 记完经历后 set-focus 连续失败且原始错误无处可查 + +- 状态:resolved +- 首次发现:2026-09-12 +- 最近更新:2026-09-12 +- 影响面:`rectification-set-focus`、`safeToolErrorCode`、工具失败回执、`mapStreamChunkToActivity` +- 用户现象:记下两件学业后,「设置对话焦点」连续失败约六次,回执只有 `tool_failed`,本轮多等约 20 秒后走宿主兜底。用户没有被卡住;GET 快照里下一问已经是「还有吗」。 +- 触发条件:batch 工具记完证据后已把同一 questionId 写成活动焦点,模型再调 set-focus;或把 `kind_hint`(如 `invite_more`)抄进 `targetKind`;或工具内抛出未列入已知列表的码。 +- 根因:失败回执只写 `safeErrorCode`,没有脱敏原文、没有 `rectification_tool_failed` 日志,无法从库或容器判断六次失败的真实原因。`invalid_case_id` / `invalid_domain` / `invalid_event_kind` / `invalid_focus` 不在已知列表,一律显示为 `tool_failed`。同一 questionId 已由 batch 落下时,set-focus 仍走 RPC 身份比较,schema 键不同会冲突;模型传入的 `kind_hint` 还会被 Zod 在 execute 前拒绝。 +- 修复:失败回执写入脱敏 `engine_message`(≤120 字,UUID/邮箱打码),并 `console.warn` `rectification_tool_failed`。上述码进入已知列表与活动码白名单。同一 questionId 已是活动焦点时幂等返回已有焦点(可更新 spoken_prompt;RPC 冲突也不得抛给模型重试)。`targetKind` / `targetDomain` schema 放宽为字符串;`kind_hint` 忽略,新焦点仍写 `target_kind=null`。非法 domain 仍在 execute 抛 `invalid_domain`。 +- 验证:`frontend/tests/rectification-set-focus-tool-failed-20260912.test.ts`(同 id 即使 RPC 冲突也 `idempotent=true`、无失败回执;`invite_more` 不拒绝;三类工具内抛错回执可辨,未知仍 `tool_failed`);`rectification-v9-status-security.test.ts`;`rectification-v9-stream.test.ts` 失败活动带码、英文重试旁白不得进正文。 +- 防复发:工具自己抛的码必须进 `safeToolErrorCode` 已知列表;失败回执必须带脱敏原文。已落下的同一 questionId 不得再让模型重试。不得把 `kind_hint` 写进 `target_kind`(BUG-656)。 +- 相关记录:BUG-442、BUG-650、BUG-656、BUG-660 +- 复发自:无 +- 修复版本:待发布 + +## BUG-660 | 宿主兜底「记下了」把日期写两遍 + +- 状态:resolved +- 首次发现:2026-09-12 +- 最近更新:2026-09-12 +- 影响面:`host-fallback.ts` `recapLine`、证据轮 `answer_origin=host_fallback` +- 用户现象:工具轮失败后宿主兜底正文写成「记下了:2016-09 2016年9月上大学…」,日期重复。 +- 触发条件:batch 回执同时有 `display_date_label`(如 `2016-09`)和已含年月的 `event_phrase`(如「2016年9月上大学」),且模型没有写出正文。 +- 根因:`recapLine` 把标签和短语直接空格拼接,不检查短语是否已含年份或以标签开头。 +- 修复:短语匹配 `\d{4}年` 或以 `display_date_label` 开头时只用短语;短语为空时仍用标签。 +- 验证:`frontend/tests/rectification-host-fallback.test.ts` 夹具「2016-09 / 2016年9月上大学」→「记下了:2016年9月上大学(本科入学)、2020年6月毕业。」;短语已以标签开头时不重复前缀。 +- 防复发:host fallback recap 不得把已含年月的短语再拼一份标签。 +- 相关记录:BUG-633、BUG-650、BUG-659 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-set-focus-tool-failed-20260912.md b/docs/tasks/PROGRESS-rectification-set-focus-tool-failed-20260912.md new file mode 100644 index 00000000..1503ebd1 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-set-focus-tool-failed-20260912.md @@ -0,0 +1,43 @@ +# 进度 · 记完经历后 set-focus 连续 `tool_failed`(2026-09-12) + +## 范围 + +- 分支:`codex/rectification-set-focus-tool-failed-20260912`(基于 `origin/staging` @ `fb18f72f`,任务书基线 `529fcc09`) +- 任务单:`docs/tasks/TASK-rectification-set-focus-tool-failed-20260912.md` +- BUG-659:失败可诊断 + 同 id 幂等 + `targetKind` 忽略 kind_hint +- BUG-660:宿主兜底 recap 去重日期 +- 不改引擎、决策门、Skill、DB 迁移 + +## T0 取证 + +本地夹具复现 §1 第二轮(batch 已落 `collect:invite:more`,再调 set-focus 同 id): + +| 假设 | 入参 / 条件 | 旧回执码(529fcc09 源码) | 本分支 | +| --- | --- | --- | --- | +| (a) schema 身份冲突 | 同 questionId;RPC `set_agentic_rectification_conversation_focus` 抛 `focus_idempotency_conflict` | 该码在已知列表,不应显示为 `tool_failed`;若走到 RPC 会是 `focus_idempotency_conflict` | 同 id 在 RPC 前返回 `idempotent=true`,无失败回执;RPC 抛错也被吞掉 | +| (b) `targetKind=invite_more` | Zod `evidenceKindSchema` | execute 前进不了,流映射 `tool_call_rejected`,与事故回执 `tool_failed` 不符 | schema 接受字符串;服务端忽略 hint,活动焦点 `target_kind=null` | +| (c) 工具内抛错 | `assertCaseRef` / `invalid_domain` | 码不在已知列表 → `tool_failed`;10ms、无 DB,与事故形态最像 | `invalid_case_id` / `invalid_domain` 回执可辨,失败指纹含脱敏 `engine_message`,并打 `rectification_tool_failed` | + +事故六次失败、每次约 10ms、界面只见 `tool_failed`,与 (c) 或「未知 throw 被压成兜底码」一致;(a)(b) 单独都不能解释成 `tool_failed`。T1 之后即使再失败也能从回执和日志分开这三类。 + +Staging 容器日志(`deploy@118.26.111.127`,密钥 `~/.ssh/jyotisha-staging`):`docker compose logs --since 2026-09-12T05:05:00Z --until 2026-09-12T05:10:00Z web` 中 `rectification_tool_failed|rectification-set-focus` 匹配 **0** 行。与 §2.2 一致:当时没有把原始错误打进日志。不以容器原文锁定根因。 + +## 完成 + +- T1 `failReceipt`:`resultFingerprint = {safe_error_code, engine_message}`;`console.warn` JSON `{event:rectification_tool_failed, tool, case_id, code, message}`。`engineMessageForReceipt` 打码 UUID/邮箱。`safeToolErrorCode` 与 `SAFE_TOOL_ACTIVITY_CODES` 加入 `invalid_case_id` / `invalid_domain` / `invalid_event_kind` / `invalid_focus` / `tool_call_rejected`;未知 tool-error 活动带 `code=tool_failed`。 +- T2 活动焦点 `questionId` 相同则返回已有焦点;spoken_prompt 合法时尝试更新 schema,RPC 失败仍成功返回。`targetKind` 不再走证据种类 Zod;新焦点 persist 仍 `targetKind: null`。 +- T3 `recapLine`:短语含 `\d{4}年` 或以标签开头时只用短语。 +- T4 本文件、BUG-659/660、CHANGELOG、任务板改为待验收。 + +## 验收(本机,未部署) + +- `frontend` `./node_modules/.bin/tsc --noEmit --incremental false`:**0 错**(约 5 分钟) +- eslint 变更文件 **0 error / 27 warning**(`rectification-v9-tools.ts` 既有 unused-import,未在本单清扫) +- 相关套件 `tsx --test` 八个文件(新合同测试、host-fallback、stream、status-security、v10 tool-contract、server-focus、v9-agent、collect-prompt):**138 pass / 0 fail** +- 新文件 `rectification-set-focus-tool-failed-20260912.test.ts`:**4 / 4** +- 未跑 `next build` / 首屏 gzip +- 未部署 staging 真机走查 + +## 未做 + +- 未 push、未部署。真机:开场说两件学业,本轮不得出现 `tool_failed`;兜底或模型正文日期不重复。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index b5b9c4fd..3e383e5a 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -174,7 +174,7 @@ | `TASK-rectification-narrow-before-deliver-20260911.md` | `PROGRESS-rectification-narrow-before-deliver-20260911.md` | 09-11 真机:池空即出卡但范围仍 20 分钟——探针只在引擎跑时按初始簇生成一次、家人题被 0.85 先验丢弃、交付前无定向补事(BUG-653/654,Skill 10.0.24) | 已实现 `6c9a0896`,已部署;验收见修复单 | `codex/rectification-narrow-before-deliver-20260911` | | `TASK-rectification-targeted-collect-persist-20260911.md` | `PROGRESS-rectification-targeted-collect-persist-20260911.md` | 09-11 真机:6c9a0896 后六题答完又无题无卡——定向/锚定/通用采集题 `kind_hint` 违反 `target_kind` CHECK 落不了库、引擎刷新用整网格空转、`targetedCollectExhausted` 按池非空判定成永久等待。执行方只拿到 T0/T3 片段,做成 BUG-655(刷新空题不写库) | 部分实现 `512be9b7`;余项见修复单 | `codex/rectification-targeted-collect-persist-20260911` | | `TASK-rectification-targeted-collect-persist-fix-20260911.md` | `PROGRESS-rectification-targeted-collect-persist-fix-20260911.md` | 修复单:BUG-656 采集焦点 kind 映射到 CHECK 枚举;BUG-657 引擎刷新按 `column_times` 取剩余候选;BUG-658 刷新尝试持久化 + 等待收窄以已问过判定(512be9b7 让无新题时 GET 永远等待且每轮重调引擎) | 已实现 `eae049d0` + `529fcc09`,已部署;静态验收通过,真机六题流程待走 | `codex/rectification-targeted-collect-persist-fix-20260911` | -| `TASK-rectification-set-focus-tool-failed-20260912.md` | `PROGRESS-rectification-set-focus-tool-failed-20260912.md` | 09-12 真机:记完两件学业后 `rectification-set-focus` 连续六次 `tool_failed`(兜底码,原始错误无处记录;焦点其实已由 batch 工具落下)、宿主兜底正文日期重复(BUG-659/660) | 待执行 | `codex/rectification-set-focus-tool-failed-20260912` | +| `TASK-rectification-set-focus-tool-failed-20260912.md` | `PROGRESS-rectification-set-focus-tool-failed-20260912.md` | 09-12 真机:记完两件学业后 `rectification-set-focus` 连续六次 `tool_failed`(兜底码,原始错误无处记录;焦点其实已由 batch 工具落下)、宿主兜底正文日期重复(BUG-659/660) | 待验收 | `codex/rectification-set-focus-tool-failed-20260912` | ## 命名与归档 diff --git a/frontend/src/lib/rectification-agentic/v9/host-fallback.ts b/frontend/src/lib/rectification-agentic/v9/host-fallback.ts index 48dd3707..53ce644b 100644 --- a/frontend/src/lib/rectification-agentic/v9/host-fallback.ts +++ b/frontend/src/lib/rectification-agentic/v9/host-fallback.ts @@ -74,6 +74,10 @@ export function lastCompletedPublicTool( 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() : ""; + if (!phrase) return label; + if (/\d{4}年/.test(phrase) || (label.length > 0 && phrase.startsWith(label))) { + return phrase; + } return [label, phrase].filter(Boolean).join(" ").trim(); } diff --git a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts index 1c8d339b..e14680c7 100644 --- a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts +++ b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts @@ -215,7 +215,11 @@ const SAFE_TOOL_ACTIVITY_CODES = new Set([ "already_exists", "focus_idempotency_conflict", "invalid_focus", + "invalid_case_id", + "invalid_domain", + "invalid_event_kind", "tool_call_rejected", + "tool_failed", ]); function safeToolActivityCode(error: unknown): string | undefined { @@ -242,12 +246,12 @@ export function mapStreamChunkToActivity(chunk: AgentChunkType): RectificationAc return { type: "tool.activity", tool: toolName, status: "started" }; } if (chunk.type === "tool-error") { - const code = safeToolActivityCode(chunk.payload?.error); + const code = safeToolActivityCode(chunk.payload?.error) ?? "tool_failed"; return { type: "tool.activity", tool: toolName, status: "failed", - ...(code ? { code } : {}), + code, }; } if (isToolInputRejection(toolResultFromChunk(chunk))) { diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 71ed056a..8c20e53b 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -2040,6 +2040,11 @@ export function safeToolErrorCode(error: unknown): string { "attempt_not_successful", "idempotency_conflict", "invalid_input", + "invalid_case_id", + "invalid_domain", + "invalid_event_kind", + "invalid_focus", + "tool_call_rejected", "invalid_choice_copy", "precision_downgrade", "offer_not_allowed", @@ -2068,7 +2073,12 @@ export function safeToolErrorCode(error: unknown): string { export function engineMessageForReceipt(error: unknown): string { const raw = error instanceof Error ? error.message : String(error); - return raw.replace(/\s+/g, " ").trim().slice(0, 120); + return raw + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "[id]") + .replace(/\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/g, "[email]") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); } export const V9_EVIDENCE_KINDS = EVIDENCE_KINDS; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 734842ed..80a7dcb7 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -810,8 +810,12 @@ function parseDossierForTools(dossier: V9CaseDossier): DossierForTools { }; } -function assertCaseRef(input: { caseId?: unknown }): void { - if (typeof input.caseId !== "string" || !uuidPattern.test(input.caseId)) { +function assertCaseRef(input: { caseId?: unknown }, boundCaseId?: string): void { + if ( + typeof input.caseId !== "string" + || !uuidPattern.test(input.caseId) + || (boundCaseId != null && input.caseId !== boundCaseId) + ) { throw new RectificationToolServiceError("invalid_case_id"); } } @@ -861,7 +865,7 @@ function occupationNormalizedLedgerRows(input: { export function createRectificationV9ReadOnlyTools(ctx: RectificationV9Context) { - const { accounting, userId } = ctx; + const { accounting, userId, caseId } = ctx; const readCaseTool = createTool({ id: "rectification-read-case", description: @@ -871,7 +875,7 @@ export function createRectificationV9ReadOnlyTools(ctx: RectificationV9Context) projection: z.enum(["turn_decision", "full_diagnostics"]).optional(), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); if ((input.projection ?? "turn_decision") === "full_diagnostics") { const compute = await loadV9CaseCompute(accounting, userId, input.caseId); @@ -915,6 +919,32 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { } }; + const failReceipt = async ( + toolName: string, + publicPhase: string, + error: unknown, + extra: { inputFingerprint?: string | null; engineVersion?: string | null } = {}, + ) => { + const code = safeToolErrorCode(error); + const message = engineMessageForReceipt(error); + console.warn(JSON.stringify({ + event: "rectification_tool_failed", + tool: toolName, + case_id: caseId, + code, + message, + })); + await receipt(toolName, publicPhase, "failed", { + inputFingerprint: extra.inputFingerprint ?? null, + engineVersion: extra.engineVersion ?? null, + safeErrorCode: code, + resultFingerprint: JSON.stringify({ + safe_error_code: code, + engine_message: message, + }), + }); + }; + const hashResult = (value: unknown): string => { try { return canonicalToolInputFingerprint("result", { value: JSON.stringify(value) }); @@ -1053,7 +1083,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { resume: z.boolean().optional(), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const inputFingerprint = canonicalToolInputFingerprint("rectification-read-case", input); await receipt("rectification-read-case", "case.loaded", "started", { inputFingerprint }); try { @@ -1100,7 +1130,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { hasReadCase = true; return projection; } catch (error) { - await receipt("rectification-read-case", "case.loaded", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); + await failReceipt("rectification-read-case", "case.loaded", error, { inputFingerprint }); throw error; } }, @@ -1116,109 +1146,161 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { intent: z.string().trim().min(1).max(160), spokenPrompt: z.string().trim().min(8).max(120), targetEvidenceId: z.string().uuid().nullable().optional(), - targetDomain: evidenceDomainSchema.nullable().optional(), - targetKind: evidenceKindSchema.nullable().optional(), + targetDomain: z.string().trim().max(160).nullable().optional(), + targetKind: z.string().trim().max(160).nullable().optional(), expectedAnswerSchema: z.record(z.unknown()).optional(), }).strict(), execute: async (input) => { - assertCaseRef(input); - if (input.targetDomain && !isEvidenceDomain(input.targetDomain)) { - throw new RectificationToolServiceError("invalid_domain"); - } - if (input.targetKind && !isEvidenceKind(input.targetKind)) { - throw new RectificationToolServiceError("invalid_event_kind"); - } const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input); - let nextFollowup: ReturnType["plan"]["next_followup"] = null; - let decisionReceipt: Readonly> | null = null; try { - const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); - const parsed = parseDossierForTools(dossier); - const active = parsed.conversationSummary.activeFocus; + assertCaseRef(input, caseId); + if (input.targetDomain && !isEvidenceDomain(input.targetDomain)) { + throw new RectificationToolServiceError("invalid_domain"); + } + let nextFollowup: ReturnType["plan"]["next_followup"] = null; + let decisionReceipt: Readonly> | null = null; + let parsedForFocus: ReturnType | null = null; + try { + const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); + const parsed = parseDossierForTools(dossier); + parsedForFocus = parsed; + const active = parsed.conversationSummary.activeFocus; + if ( + setFocusCompleted > 0 + && active + && isPersistedFocusId(active.id) + && active.askedTurnId === turnId + ) { + const projection = { + focus_id: active.id, + question_id: active.questionId, + intent: active.intent, + target_evidence_id: active.targetEvidenceId, + target_domain: active.targetDomain, + target_kind: active.targetKind, + expected_answer_schema: active.expectedAnswerSchema, + status: active.status, + asked_at: active.askedAt, + idempotent: true, + }; + await receipt("rectification-set-focus", "intent.classified", "completed", { + inputFingerprint, + resultFingerprint: hashResult(projection), + }); + return projection; + } + nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup; + decisionReceipt = parsed.latestResult?.decisionReceipt ?? null; + } catch { + nextFollowup = null; + } + const activeFocus = parsedForFocus?.conversationSummary.activeFocus ?? null; if ( - setFocusCompleted > 0 - && active - && isPersistedFocusId(active.id) - && active.askedTurnId === turnId + activeFocus + && isPersistedFocusId(activeFocus.id) + && activeFocus.questionId === input.questionId ) { + const spoken = validateSpokenPrompt({ + spokenPrompt: input.spokenPrompt, + followup: nextFollowup, + targetDomain: input.targetDomain ?? nextFollowup?.domain ?? activeFocus.targetDomain, + questionId: input.questionId, + }); + let focus = activeFocus; + if (spoken.ok) { + try { + const result = await setV10ConversationFocus(accounting, userId, input.caseId, { + questionId: activeFocus.questionId, + intent: activeFocus.intent, + targetEvidenceId: activeFocus.targetEvidenceId, + targetDomain: activeFocus.targetDomain, + targetKind: activeFocus.targetKind, + expectedAnswerSchema: withSpokenPrompt( + { ...activeFocus.expectedAnswerSchema }, + spoken.prompt, + ), + askedTurnId: turnId, + }); + focus = result.focus; + } catch { + // Same questionId is already active. Do not compare schema keys or + // throw; the model must not retry this write. + focus = activeFocus; + } + } const projection = { - focus_id: active.id, - question_id: active.questionId, - intent: active.intent, - target_evidence_id: active.targetEvidenceId, - target_domain: active.targetDomain, - target_kind: active.targetKind, - expected_answer_schema: active.expectedAnswerSchema, - status: active.status, - asked_at: active.askedAt, + focus_id: focus.id, + question_id: focus.questionId, + intent: focus.intent, + target_evidence_id: focus.targetEvidenceId, + target_domain: focus.targetDomain, + target_kind: focus.targetKind, + expected_answer_schema: focus.expectedAnswerSchema, + status: focus.status, + asked_at: focus.askedAt, idempotent: true, }; await receipt("rectification-set-focus", "intent.classified", "completed", { inputFingerprint, resultFingerprint: hashResult(projection), }); + setFocusCompleted += 1; return projection; } - nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup; - decisionReceipt = parsed.latestResult?.decisionReceipt ?? null; - } catch { - nextFollowup = null; - } - if (!nextFollowup) { - await receipt("rectification-set-focus", "intent.classified", "failed", { - inputFingerprint, - safeErrorCode: "no_pending_question", - }); - return { ok: false, error: "no_pending_question" }; - } - const spoken = validateSpokenPrompt({ - spokenPrompt: input.spokenPrompt, - followup: nextFollowup, - targetDomain: input.targetDomain ?? nextFollowup.domain ?? null, - questionId: input.questionId, - }); - let spokenText = spoken.ok ? spoken.prompt : ""; - if (!spoken.ok) { - spokenPromptFailures += 1; - await receipt("rectification-set-focus", "intent.classified", "failed", { - inputFingerprint, - safeErrorCode: "invalid_spoken_prompt", - resultFingerprint: JSON.stringify({ reason: spoken.reason }), - }); - const fallbackDomain = nextFollowup.domain ?? ""; - const fallbackPrompt = fallbackDomain && fallbackDomain !== "other" - ? collectQuestionForDomain(fallbackDomain, nextFollowup.probe_year) - : undefined; - if ( - spokenPromptFailures < 2 - || nextFollowup.intent !== "collect_method_evidence" - || !fallbackPrompt - ) { - return { - ok: false, - error: "invalid_spoken_prompt", - reason: spoken.reason, - retryable: spokenPromptFailures < 2, - }; + if (!nextFollowup) { + await receipt("rectification-set-focus", "intent.classified", "failed", { + inputFingerprint, + safeErrorCode: "no_pending_question", + }); + return { ok: false, error: "no_pending_question" }; } - spokenText = fallbackPrompt; - } - let persistFollowup = nextFollowup; - let expectedAnswerSchema = serverOwnedExpectedAnswerSchema(persistFollowup, decisionReceipt); - if ( - persistFollowup.intent === "collect_method_evidence" - && persistFollowup.choice_frame - && !expectedAnswerSchema?.choice - ) { - persistFollowup = spokenCollectFallbackFollowup(persistFollowup); - expectedAnswerSchema = collectFocusSchema(persistFollowup); - } - if (!expectedAnswerSchema) { - expectedAnswerSchema = { [COLLECT_FOCUS_SCHEMA_KEY]: true }; - } - expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spokenText); - await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint }); - try { + const spoken = validateSpokenPrompt({ + spokenPrompt: input.spokenPrompt, + followup: nextFollowup, + targetDomain: input.targetDomain ?? nextFollowup.domain ?? null, + questionId: input.questionId, + }); + let spokenText = spoken.ok ? spoken.prompt : ""; + if (!spoken.ok) { + spokenPromptFailures += 1; + await receipt("rectification-set-focus", "intent.classified", "failed", { + inputFingerprint, + safeErrorCode: "invalid_spoken_prompt", + resultFingerprint: JSON.stringify({ reason: spoken.reason }), + }); + const fallbackDomain = nextFollowup.domain ?? ""; + const fallbackPrompt = fallbackDomain && fallbackDomain !== "other" + ? collectQuestionForDomain(fallbackDomain, nextFollowup.probe_year) + : undefined; + if ( + spokenPromptFailures < 2 + || nextFollowup.intent !== "collect_method_evidence" + || !fallbackPrompt + ) { + return { + ok: false, + error: "invalid_spoken_prompt", + reason: spoken.reason, + retryable: spokenPromptFailures < 2, + }; + } + spokenText = fallbackPrompt; + } + let persistFollowup = nextFollowup; + let expectedAnswerSchema = serverOwnedExpectedAnswerSchema(persistFollowup, decisionReceipt); + if ( + persistFollowup.intent === "collect_method_evidence" + && persistFollowup.choice_frame + && !expectedAnswerSchema?.choice + ) { + persistFollowup = spokenCollectFallbackFollowup(persistFollowup); + expectedAnswerSchema = collectFocusSchema(persistFollowup); + } + if (!expectedAnswerSchema) { + expectedAnswerSchema = { [COLLECT_FOCUS_SCHEMA_KEY]: true }; + } + expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spokenText); + await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint }); const result = await setV10ConversationFocus(accounting, userId, input.caseId, { questionId: stableFollowupQuestionId(persistFollowup), intent: persistFollowup.intent, @@ -1249,10 +1331,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { setFocusCompleted += 1; return projection; } catch (error) { - await receipt("rectification-set-focus", "intent.classified", "failed", { - inputFingerprint, - safeErrorCode: safeToolErrorCode(error), - }); + await failReceipt("rectification-set-focus", "intent.classified", error, { inputFingerprint }); throw error; } }, @@ -1270,7 +1349,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { choiceKey: z.enum(["A", "B", "C", "D"]).nullable().optional(), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const inputFingerprint = canonicalToolInputFingerprint("rectification-resolve-focus", input); await receipt("rectification-resolve-focus", "intent.classified", "started", { inputFingerprint }); try { @@ -1368,10 +1447,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return projection; } catch (error) { - await receipt("rectification-resolve-focus", "intent.classified", "failed", { - inputFingerprint, - safeErrorCode: safeToolErrorCode(error), - }); + await failReceipt("rectification-resolve-focus", "intent.classified", error, { inputFingerprint }); throw error; } }, @@ -1398,7 +1474,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }).strict()).min(1).max(12), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); if (!hasReadCase) { await loadV9CaseDossier(accounting, userId, input.caseId); hasReadCase = true; @@ -1574,10 +1650,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return projection; } catch (error) { - await receipt("rectification-record-evidence-batch", "evidence.proposed", "failed", { - inputFingerprint, - safeErrorCode: safeToolErrorCode(error), - }); + await failReceipt("rectification-record-evidence-batch", "evidence.proposed", error, { inputFingerprint }); throw error; } }, @@ -1599,28 +1672,28 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { summary: z.string().trim().min(1).max(1000), }).strict(), execute: async (input) => { - assertCaseRef(input); - if (!isEvidenceKind(input.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind"); - if (!isEvidenceDomain(input.domain)) throw new RectificationToolServiceError("invalid_domain"); - if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision"); - if (isHoldoutVerificationQuote(input.quote)) { - return { - evidence_id: null, - status: "rejected", - outcome: "rejected", - error_code: "holdout_not_scored", - idempotent: false, - note: "盘外核对不得写入可评分证据,也不会改候选分数。", - }; - } const inputFingerprint = canonicalToolInputFingerprint("rectification-propose-evidence", { caseId: input.caseId, sourceTurnId: turnId, quote: input.quote, proposedKind: input.proposedKind, }); - await receipt("rectification-propose-evidence", "evidence.proposed", "started", { inputFingerprint }); try { + assertCaseRef(input, caseId); + if (!isEvidenceKind(input.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind"); + if (!isEvidenceDomain(input.domain)) throw new RectificationToolServiceError("invalid_domain"); + if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision"); + if (isHoldoutVerificationQuote(input.quote)) { + return { + evidence_id: null, + status: "rejected", + outcome: "rejected", + error_code: "holdout_not_scored", + idempotent: false, + note: "盘外核对不得写入可评分证据,也不会改候选分数。", + }; + } + await receipt("rectification-propose-evidence", "evidence.proposed", "started", { inputFingerprint }); const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null; const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null; const occupationFocus = await occupationCollectFocusForWrite(input.caseId); @@ -1671,7 +1744,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { : "未能写入。quote 必须是当前用户消息中的连续原文子串,且 kind/domain 必须属于服务器枚举。不要把这一轮标成已纳入校正依据。", }; } catch (error) { - await receipt("rectification-propose-evidence", "evidence.proposed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); + await failReceipt("rectification-propose-evidence", "evidence.proposed", error, { inputFingerprint }); throw error; } }, @@ -1687,7 +1760,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { evidenceId: z.string().uuid(), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const evidenceId = assertEvidenceRef(input); const inputFingerprint = canonicalToolInputFingerprint("rectification-confirm-evidence", input); await receipt("rectification-confirm-evidence", "evidence.confirmed", "started", { inputFingerprint }); @@ -1724,7 +1797,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return projection; } catch (error) { - await receipt("rectification-confirm-evidence", "evidence.confirmed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); + await failReceipt("rectification-confirm-evidence", "evidence.confirmed", error, { inputFingerprint }); throw error; } }, @@ -1745,7 +1818,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { summary: z.string().trim().min(1).max(1000), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const evidenceId = assertEvidenceRef(input); if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision"); const inputFingerprint = canonicalToolInputFingerprint("rectification-revise-evidence", { @@ -1778,7 +1851,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { idempotent: result.idempotent, }; } catch (error) { - await receipt("rectification-revise-evidence", "evidence.proposed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); + await failReceipt("rectification-revise-evidence", "evidence.proposed", error, { inputFingerprint }); throw error; } }, @@ -1790,7 +1863,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { "用当前已确认证据运行候选比较。服务端按事件的实际领域选择分盘,并结合 Vimshottari、Narayana 与稳健性诊断。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。", inputSchema: z.object({ caseId: z.string().uuid() }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const inputFingerprint = canonicalToolInputFingerprint("rectification-compare-candidates", input); await receipt("rectification-compare-candidates", "candidates.comparing", "started", { inputFingerprint, engineVersion }); try { @@ -1851,15 +1924,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return { ...projection, executed_methods: scored.score.executedMethods }; } catch (error) { - const safeErrorCode = safeToolErrorCode(error); - await receipt("rectification-compare-candidates", "candidates.comparing", "failed", { + await failReceipt("rectification-compare-candidates", "candidates.comparing", error, { inputFingerprint, engineVersion, - safeErrorCode, - resultFingerprint: JSON.stringify({ - safe_error_code: safeErrorCode, - engine_message: engineMessageForReceipt(error), - }), }); throw error; } @@ -1872,7 +1939,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { "读取候选稳健性诊断:留一事件/留一领域保留率、日期敏感性、邻域稳定性、不稳定事件与最能区分候选的技法层。用于决定下一问补哪个方向。不返回原始分数。", inputSchema: z.object({ caseId: z.string().uuid() }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const inputFingerprint = canonicalToolInputFingerprint("rectification-read-diagnostics", input); await receipt("rectification-read-diagnostics", "diagnostics.completed", "started", { inputFingerprint, engineVersion }); try { @@ -1907,11 +1974,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return { ...projection, executed_methods: diagnostics.executedMethods }; } catch (error) { - await receipt("rectification-read-diagnostics", "diagnostics.completed", "failed", { - inputFingerprint, - engineVersion, - safeErrorCode: safeToolErrorCode(error), - }); + await failReceipt("rectification-read-diagnostics", "diagnostics.completed", error, { inputFingerprint, engineVersion }); throw error; } }, @@ -1923,7 +1986,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { "把已持久化的候选快照呈现给用户(当前候选/相对支持度,不是概率或确定性)。仅在 session_outcome 为 adopt_representative、validated_range、provisional_range、provisional_range_user_stopped、completed_with_range、exact_minute_confirmed 或 awaiting_confirmation 时允许调用;访谈仍在收集或候选区分时拒绝。completed_with_range 与用户停止表示区间交付已完成;只能说明未形成唯一分钟确认,不得把本次校正写成失败或未完成。不会在同一回复中要求继续补证据。", inputSchema: z.object({ caseId: z.string().uuid() }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const inputFingerprint = canonicalToolInputFingerprint("rectification-offer-candidates", input); await receipt("rectification-offer-candidates", "candidates.updated", "started", { inputFingerprint }); try { @@ -1995,7 +2058,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return payload; } catch (error) { - await receipt("rectification-offer-candidates", "candidates.updated", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); + await failReceipt("rectification-offer-candidates", "candidates.updated", error, { inputFingerprint }); throw error; } }, @@ -2011,7 +2074,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { candidateId: z.string().uuid(), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const { resultId } = assertCandidateRef({ resultId: input.resultId, candidateId: input.candidateId }); const inputFingerprint = canonicalToolInputFingerprint("rectification-accept-candidate", input); await receipt("rectification-accept-candidate", "candidate.accepted", "started", { inputFingerprint }); @@ -2047,7 +2110,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return projection; } catch (error) { - await receipt("rectification-accept-candidate", "candidate.accepted", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); + await failReceipt("rectification-accept-candidate", "candidate.accepted", error, { inputFingerprint }); throw error; } }, @@ -2064,7 +2127,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { consentQuote: z.string().trim().min(2).max(400), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const { resultId } = assertCandidateRef({ resultId: input.resultId, candidateId: input.candidateId }); const inputFingerprint = canonicalToolInputFingerprint("rectification-confirm-birth-time", { caseId: input.caseId, @@ -2103,7 +2166,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return projection; } catch (error) { - await receipt("rectification-confirm-birth-time", "birth_time.confirmed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); + await failReceipt("rectification-confirm-birth-time", "birth_time.confirmed", error, { inputFingerprint }); throw error; } }, @@ -2118,7 +2181,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { reason: z.enum(["completed_by_user", "abandoned_by_user", "other"]).default("completed_by_user"), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const inputFingerprint = canonicalToolInputFingerprint("rectification-close-case", input); await receipt("rectification-close-case", "run.completed", "started", { inputFingerprint }); try { @@ -2134,7 +2197,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return projection; } catch (error) { - await receipt("rectification-close-case", "run.completed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); + await failReceipt("rectification-close-case", "run.completed", error, { inputFingerprint }); throw error; } }, @@ -2148,7 +2211,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { caseId: z.string().uuid(), }).strict(), execute: async (input) => { - assertCaseRef(input); + assertCaseRef(input, caseId); const inputFingerprint = canonicalToolInputFingerprint("rectification-stop-and-review", input); await receipt("rectification-stop-and-review", "intent.classified", "started", { inputFingerprint }); try { @@ -2173,10 +2236,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return projection; } catch (error) { - await receipt("rectification-stop-and-review", "intent.classified", "failed", { - inputFingerprint, - safeErrorCode: safeToolErrorCode(error), - }); + await failReceipt("rectification-stop-and-review", "intent.classified", error, { inputFingerprint }); throw error; } }, diff --git a/frontend/tests/rectification-host-fallback.test.ts b/frontend/tests/rectification-host-fallback.test.ts index abb6db0d..a9d983a8 100644 --- a/frontend/tests/rectification-host-fallback.test.ts +++ b/frontend/tests/rectification-host-fallback.test.ts @@ -25,6 +25,23 @@ test("host fallback recap uses only batch return lines", () => { }), "记下了:2016年9月 入学、2020年6月 毕业。", ); + assert.equal( + composeHostFallbackNarration({ + accepted_recaps: [ + { display_date_label: "2016-09", event_phrase: "2016年9月上大学(本科入学)" }, + { display_date_label: "2020-06", event_phrase: "2020年6月毕业" }, + ], + }), + "记下了:2016年9月上大学(本科入学)、2020年6月毕业。", + ); + assert.equal( + composeHostFallbackNarration({ + accepted_recaps: [ + { display_date_label: "2016-09", event_phrase: "2016-09 上大学" }, + ], + }), + "记下了:2016-09 上大学。", + ); assert.equal( composeHostFallbackNarration({ items: [ diff --git a/frontend/tests/rectification-set-focus-tool-failed-20260912.test.ts b/frontend/tests/rectification-set-focus-tool-failed-20260912.test.ts new file mode 100644 index 00000000..dd521279 --- /dev/null +++ b/frontend/tests/rectification-set-focus-tool-failed-20260912.test.ts @@ -0,0 +1,275 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isToolInputRejection } from "../src/lib/rectification-agentic/v9/host-fallback.ts"; +import { mapStreamChunkToActivity } from "../src/lib/rectification-agentic/v9/stream-mapping.ts"; +import { + engineMessageForReceipt, + parseToolActivityDetail, + RectificationToolServiceError, + safeToolErrorCode, +} from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + activeFocusFixture, + conversationSummaryFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +type ExecutableTool = { + execute(input: unknown): Promise; + inputSchema?: { safeParse(value: unknown): { success: boolean } }; +}; + +function asExecutable(tool: unknown): ExecutableTool { + return tool as ExecutableTool; +} + +const INVITE_QUESTION_ID = "collect:invite:more"; +const INVITE_PROMPT = "还有吗?把能想起来的都说一声。"; +const INVITE_SPOKEN = "还有别的记得起来的事情吗?"; +const OTHER_CASE_ID = "21111111-1111-4111-8111-111111111111"; + +function toolsFor(accounting: ReturnType) { + return createRectificationV9Tools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + accounting: accounting.client as never, + }); +} + +function inviteFocus() { + return { + ...activeFocusFixture({ + questionId: INVITE_QUESTION_ID, + intent: "collect_method_evidence", + targetDomain: "other", + expectedAnswerSchema: { + prompt: INVITE_PROMPT, + collect: true, + collect_kind: "invite_more", + }, + }), + target_kind: null, + }; +} + +function inviteDossier() { + return dossierFixture({ + evidenceCount: 2, + evidence: [ + { + id: "44444444-4444-4444-8444-444444444445", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "education_start", + domain: "education", + occurred_from: "2016-09-01", + occurred_to: "2016-09-01", + date_precision: "month", + summary: "2016年9月上大学", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-12T05:00:00.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444446", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "education_completion", + domain: "education", + occurred_from: "2020-06-01", + occurred_to: "2020-06-01", + date_precision: "month", + summary: "2020年6月毕业", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-12T05:00:01.000Z", + }, + ], + conversationSummary: conversationSummaryFixture({ activeFocus: inviteFocus() }), + }); +} + +function failedReceipts(accounting: ReturnType) { + return accounting.calls.filter((call) => + call.fn === "insert_agentic_rectification_tool_receipt" + && call.args.p_status === "failed" + ); +} + +function setFocusArgs(overrides: Record = {}) { + return { + caseId: CASE_ID, + questionId: INVITE_QUESTION_ID, + intent: "collect_method_evidence", + spokenPrompt: INVITE_SPOKEN, + targetKind: "invite_more", + ...overrides, + }; +} + +async function invoke(tool: ExecutableTool, input: unknown) { + try { + return { result: await tool.execute(input), error: null }; + } catch (error) { + return { result: null, error }; + } +} + +test("T0/T2: same invite focus is idempotent even when the RPC would conflict", async () => { + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => inviteDossier(), + set_agentic_rectification_conversation_focus: () => { + throw new Error("agentic_rectification_focus_idempotency_conflict"); + }, + }); + const execute = asExecutable>(toolsFor(accounting)["rectification-set-focus"]).execute; + const result = await execute(setFocusArgs()); + assert.equal(result.error, undefined); + assert.equal(result.status, "active"); + assert.equal(result.idempotent, true); + assert.equal(result.question_id, INVITE_QUESTION_ID); + assert.equal(result.focus_id, FOCUS_ID); + assert.equal(result.target_kind, null); + assert.equal(failedReceipts(accounting).length, 0); +}); + +test("targetKind kind_hint is accepted and clamped to null", async () => { + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => inviteDossier(), + set_agentic_rectification_conversation_focus: () => { + throw new Error("set-focus must not reject invite_more by writing a new row"); + }, + }); + const tools = toolsFor(accounting); + const setFocus = asExecutable>(tools["rectification-set-focus"]); + assert.equal(setFocus.inputSchema?.safeParse(setFocusArgs()).success, true); + assert.equal(setFocus.inputSchema?.safeParse(setFocusArgs({ targetKind: "targeted:family" })).success, true); + const result = await setFocus.execute(setFocusArgs({ targetKind: "invite_more" })); + assert.equal(result.idempotent, true); + assert.equal(result.target_kind, null); +}); + +test("tool-internal throws write distinguishable failed receipts; unknown stays tool_failed", async () => { + assert.equal( + safeToolErrorCode(new RectificationToolServiceError("invalid_case_id")), + "invalid_case_id", + ); + assert.equal( + safeToolErrorCode(new RectificationToolServiceError("invalid_domain")), + "invalid_domain", + ); + assert.equal( + safeToolErrorCode(new RectificationToolServiceError("invalid_event_kind")), + "invalid_event_kind", + ); + assert.equal(safeToolErrorCode(new Error("connection refused")), "tool_failed"); + assert.equal( + engineMessageForReceipt(new Error(`boom ${CASE_ID} user@example.com`)), + "boom [id] [email]", + ); + + const warns: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warns.push(args.map(String).join(" ")); + }; + try { + const caseAccounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => inviteDossier(), + }); + const setFocus = asExecutable(toolsFor(caseAccounting)["rectification-set-focus"]); + const schemaRejected = await invoke(setFocus, setFocusArgs({ caseId: "not-a-uuid" })); + assert.equal(isToolInputRejection(schemaRejected.result), true); + assert.equal(failedReceipts(caseAccounting).length, 0); + + const wrongCase = await invoke(setFocus, setFocusArgs({ caseId: OTHER_CASE_ID })); + assert.equal( + wrongCase.error instanceof RectificationToolServiceError && wrongCase.error.code, + "invalid_case_id", + ); + const caseFailed = failedReceipts(caseAccounting); + assert.equal(caseFailed[0]?.args.p_safe_error_code, "invalid_case_id"); + assert.match(String(caseFailed[0]?.args.p_result_fingerprint), /invalid_case_id/); + assert.match(String(caseFailed[0]?.args.p_result_fingerprint), /engine_message/); + assert.match(warns.join("\n"), /rectification_tool_failed/); + assert.match(warns.join("\n"), /invalid_case_id/); + + const domainAccounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => inviteDossier(), + }); + const domainFocus = asExecutable(toolsFor(domainAccounting)["rectification-set-focus"]); + const domainResult = await invoke(domainFocus, setFocusArgs({ targetDomain: "not_a_domain" })); + assert.equal( + domainResult.error instanceof RectificationToolServiceError && domainResult.error.code, + "invalid_domain", + ); + assert.equal(failedReceipts(domainAccounting)[0]?.args.p_safe_error_code, "invalid_domain"); + + const kindAccounting = fakeAccounting({ ...receiptHandlers }); + const propose = asExecutable(toolsFor(kindAccounting)["rectification-propose-evidence"]); + const kindRejected = await invoke(propose, { + caseId: CASE_ID, + quote: "2016年9月上大学", + proposedKind: "invite_more", + subject: "self", + domain: "education", + datePrecision: "month", + occurredFrom: "2016-09", + summary: "2016年9月上大学", + }); + assert.equal(isToolInputRejection(kindRejected.result), true); + } finally { + console.warn = original; + } + + const detail = parseToolActivityDetail({ + error: "tool_failed", + result_fingerprint: JSON.stringify({ + safe_error_code: "tool_failed", + engine_message: "connection refused", + }), + }); + assert.equal(detail?.safe_error_code, "tool_failed"); + assert.equal(detail?.engine_message, "connection refused"); +}); + +test("tool-error activity keeps invalid_case_id instead of collapsing to tool_failed", () => { + assert.deepEqual( + mapStreamChunkToActivity({ + type: "tool-error", + payload: { + toolName: "rectification-set-focus", + error: new Error("Rectification tool service error: invalid_case_id"), + }, + } as never), + { + type: "tool.activity", + tool: "rectification-set-focus", + status: "failed", + code: "invalid_case_id", + }, + ); + assert.equal( + mapStreamChunkToActivity({ + type: "tool-error", + payload: { + toolName: "rectification-set-focus", + error: new Error("Rectification tool service error: invalid_event_kind"), + }, + } as never)?.code, + "invalid_event_kind", + ); +}); diff --git a/frontend/tests/rectification-v9-status-security.test.ts b/frontend/tests/rectification-v9-status-security.test.ts index 31bb3d9e..816fb830 100644 --- a/frontend/tests/rectification-v9-status-security.test.ts +++ b/frontend/tests/rectification-v9-status-security.test.ts @@ -9,6 +9,7 @@ import { evidenceLedgerFingerprint, parseV9CaseDossier, parseV9ComputeProjection, + RectificationToolServiceError, safeToolErrorCode, } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { @@ -680,6 +681,8 @@ test("safe tool error mapping downgrades unknown engine failures", () => { "case_terminal", ); assert.equal(safeToolErrorCode(new Error("connection refused")), "tool_failed"); + assert.equal(safeToolErrorCode(new RectificationToolServiceError("invalid_case_id")), "invalid_case_id"); + assert.equal(safeToolErrorCode(new RectificationToolServiceError("invalid_event_kind")), "invalid_event_kind"); }); test("compare-candidates reuses a matching fingerprint without calling the engine", async () => { diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index 492b58d1..f4209eb8 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -122,7 +122,7 @@ test("every public rectification tool maps its real lifecycle to public activity args: { caseId: CASE_ID, scores: [99] }, error: new Error("private provider error"), }) as never), - { type: "tool.activity", tool, status: "failed" }, + { type: "tool.activity", tool, status: "failed", code: "tool_failed" }, ); } assert.equal(mapStreamChunkToActivity(chunk("tool-call", { toolName: "skill" }) as never), null); @@ -322,7 +322,7 @@ function fakeAgentStream(chunks: Array<{ type: string; payload?: Record = {}) { - const emitted: Array<{ type: string; text?: string; turnId?: string }> = []; + const emitted: Array<{ type: string; text?: string; turnId?: string; status?: string; code?: string }> = []; const billing = { reserved: 0, completed: 0, released: 0 }; const accounting = fakeAccounting({ ...receiptHandlers, @@ -560,9 +560,16 @@ test("English tool-retry narration never becomes the spoken answer", async () => emitted.filter((event) => event.type === "thinking.delta"), [], ); - const publicText = JSON.stringify(emitted); - assert.doesNotMatch(publicText, /The proposedKind value was rejected/); - assert.doesNotMatch(publicText, /invalid_event_kind/); + const spokenAndThinking = JSON.stringify( + emitted.filter((event) => event.type === "answer.delta" || event.type === "thinking.delta"), + ); + assert.doesNotMatch(spokenAndThinking, /The proposedKind value was rejected/); + assert.doesNotMatch(spokenAndThinking, /invalid_event_kind/); + assert.doesNotMatch(JSON.stringify(emitted), /The proposedKind value was rejected/); + assert.equal( + emitted.find((event) => event.type === "tool.activity" && event.status === "failed")?.code, + "invalid_event_kind", + ); }); test("Chinese process self-talk after tools is thinking, not the spoken answer", async () => {