diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 9aa14b11..4d4bf812 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5531,6 +5531,38 @@ - 复发自:无 - 修复版本:未修复 +## BUG-377 | 生时纠正直播回复必须是模型正文,不得用正则或 Case 模板顶替 + +- 状态:resolved +- 首次发现:2026-08-25 +- 最近更新:2026-08-25 +- 影响面:`runV9AgentTurn`、纠正组 `thinking`、公开 `answer.delta`、生时纠正聊天气泡 +- 用户现象:关 thinking 后模型把规划写进 `text-delta`。漏检网按句式切口语,换一种说法就再次把规划当回复。用服务器按 Case 拼「已经记下」会丢掉模型自己的访谈措辞。 +- 触发条件:纠正组 `thinking: disabled`;公开流不发 `thinking.delta`。工具完成后的最终 `text-delta` 混有规划和对用户说话,或直播路径用正则 / Case 模板决定回复。 +- 根因:规划没有稳定表面形式,词表无法从同一段散文里拆出口语。关掉 provider thinking 后模型没有隐藏思维链通道,只能写进正文。用服务器叙述顶替正文等于不用大模型写对用户说的话。 +- 修复:直播用户可见回复是模型终端 `text-delta` 原样。打开 hidden thinking(`reasoning-delta`),正文预算与思维链预算分开,规划离开正文。公开流仍丢弃 `thinking.delta`。纠正 UI 不渲染 `thinkingText`,阶段仍来自工具进度。服务器叙述只在工具后没有正文时兜底。正则只用于旧 Turn 水合,不得再当直播分流。不改 Skill `10.0.11`。 +- 验证:`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/rectification-spoken-answer.test.ts`。 +- 防复发:禁止在 `runV9AgentTurn` 里用 `splitRectificationSpokenAndThinking` 决定 `answer.delta`。禁止用 Case 模板替换已有模型正文。禁止把 `thinking.delta` 发给纠正公开流或写成用户可见思考块。 +- 相关记录:BUG-357、BUG-368、BUG-373、BUG-374、BUG-376 +- 复发自:BUG-376(继续加正则切规划;随后一度误用服务器叙述顶替模型回复) +- 修复版本:待发布 + +## BUG-376 | 生时纠正再次把中文规划写进回复:账本/草稿/探针/我应该 + +- 状态:resolved +- 首次发现:2026-08-25 +- 最近更新:2026-08-25 +- 影响面:`POST /api/rectification/agent`、`splitRectificationSpokenAndThinking`、生时纠正聊天气泡 +- 用户现象:工具阶段(读取校正记录、整理证据、比较候选)之后,回复气泡里先出现大段自我规划:账本几件已确认、草稿待确认、当前探针、current_question、我应该继续访谈,最后才是对用户说的话。有一轮整段都是规划,没有口语。 +- 触发条件:纠正组 `thinking: disabled`;公开流不发 `thinking.delta`。模型把中文规划写进最终 `text-delta`。句式不在 BUG-374 漏检网里。 +- 根因:漏检网只认上一轮字段名(occupation_note / candidate_contrast_packet / 这意味着)。新规划改用「账本 / 草稿 / 探针 / 用户后面 / 我应该 / 根据规则 / current_question」。整段匹配失败后被当成口语并落盘。界面还把拆出的 thinkingText 渲成灰色思考块。 +- 修复:纠正聊天不再渲染思考正文,只保留阶段进度和口语。漏检网补上账本/草稿/探针等句式,仅用于旧 Turn 水合,不得当直播分流。直播根因由 BUG-377 处理:打开 hidden thinking,用户可见回复用模型 `text-delta`。不改 Skill `10.0.11`。 +- 验证:`frontend/tests/rectification-spoken-answer.test.ts`、`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`。 +- 防复发:纠正 UI 不得把 `thinkingText` 当作用户可见输出。直播路径不得再靠加词表从正文切思考。规划必须走 `reasoning-delta`。 +- 相关记录:BUG-357、BUG-368、BUG-373、BUG-374、BUG-377 +- 复发自:BUG-374(关 thinking 后用正则从正文切思考;句式换了就漏) +- 修复版本:待发布 + ## BUG-375 | 生时纠正分不开时 A/B/C/D 出不来,职业回答不计分,「没有了」不停问 - 状态:resolved diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 179b1c28..7b198f6d 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -52,10 +52,7 @@ import { stableChoiceActionKey, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; -import { - finalizeRectificationSpokenAndThinking, - settleRectificationSpokenAndThinking, -} from "@/lib/rectification-agentic/v9/spoken-answer"; +import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer"; import { CHOICE_STOP_MESSAGE, choiceCardUserMessage, @@ -229,11 +226,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag if (failed && !raw) return []; if (isIncompleteRunBanner(raw)) return []; const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw }; - const thinkingText = split.thinking.trim() || undefined; return [{ role: "assistant", text: split.spoken, - ...(thinkingText ? { thinkingText } : {}), renderKey: key, state: turn.status === "completed" || failed ? "settled" : "thinking", completedReceipt: completedReceiptFromPersisted(turn.receipt), @@ -449,7 +444,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setDraft(""); let raw = ""; - let thinkingRaw = ""; let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace(); let activityReceiptState = createRectificationActivityReceiptState(); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); @@ -528,14 +522,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (event.type === "answer.delta" && typeof event.text === "string") { raw += event.text; activityTrace = freezeLiveThink(activityTrace); - const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, - text: settled.spoken, - thinkingText: settled.thinking.trim() || undefined, + text: raw, activityTrace, - state: settled.spoken ? "streaming" : "thinking", + state: raw.trim() ? "streaming" : "thinking", activity: nextActivityView(message.activity, { phase: "answer-composition", label: "正在组织回答…", @@ -555,7 +547,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { : message)); } else if (event.type === "attempt.reset") { raw = ""; - thinkingRaw = ""; activityTrace = emptyActivityTrace(); activityReceiptState = createRectificationActivityReceiptState(); completedReceipt = receiptFromRectificationActivityState(activityReceiptState); @@ -639,8 +630,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } } - const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw); - const parsed = completed && !streamFailed ? parseAgentReply(settled.spoken) : { text: "", title: undefined }; + const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined }; const succeeded = completed && !streamFailed && Boolean(parsed.text); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; @@ -648,7 +638,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { return [{ ...message, text: parsed.text, - thinkingText: settled.thinking.trim() || undefined, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, @@ -657,11 +646,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { activity: undefined, }]; } - if (streamFailed || hasActivityReceipt(completedReceipt) || settled.spoken.trim()) { + if (streamFailed || hasActivityReceipt(completedReceipt) || raw.trim()) { return [{ ...message, - text: settled.spoken, - thinkingText: settled.thinking.trim() || undefined, + text: raw, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, @@ -671,7 +659,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } return []; })); - if (!succeeded && settled.spoken.trim()) { + if (!succeeded && raw.trim()) { setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。"); } else if (!succeeded && runFailedMessage) { setError((current) => current || runFailedMessage); @@ -691,14 +679,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? caught.name === "AbortError" : caught instanceof Error && caught.name === "AbortError"; if (aborted) { - const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; - if (settled.spoken.trim() || settled.thinking.trim() || hasActivityReceipt(completedReceipt)) { + if (raw.trim() || hasActivityReceipt(completedReceipt)) { return [{ ...message, - text: settled.spoken, - thinkingText: settled.thinking.trim() || undefined, + text: raw, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 13bf9731..f9936755 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -1,10 +1,13 @@ /** * V10 rectification turn runner over the durable V9 Case/Evidence domains. * - * Tool activity and answer tokens are published as they happen so the browser - * can render progress. A retried attempt emits `attempt.reset` first so the - * client discards the abandoned attempt's visible text. Durable receipts, - * billing, and settled history still come only from the successful attempt. + * Tool activity is published as it happens so the browser can render stages. + * The user-visible reply is the model's terminal `text-delta`. Provider + * thinking stays on `reasoning-delta` and is not a public stream event. + * Server narration is only the empty-stream fallback after tools. A retried + * attempt emits `attempt.reset` first so the client discards the abandoned + * attempt. Durable receipts, billing, and settled history still come only + * from the successful attempt. */ import type { Agent } from "@mastra/core/agent"; import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification"; @@ -35,7 +38,6 @@ import { isPublicRectificationToolName, type PublicStreamEvent, } from "./stream-mapping"; -import { splitRectificationSpokenAndThinking } from "./spoken-answer"; import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic"; import { applyStepAnswerChunk, @@ -575,8 +577,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise { - const spoken = splitRectificationSpokenAndThinking(pieces.join("")).spoken.trim(); + // The model's terminal text-delta is the user-visible reply. Do not + // regex-split it, and do not replace it with Case narration. + const spoken = pieces.join("").trim(); if (!spoken || !caseLoaded) return; answerText += spoken; answerDeltas.push(spoken); @@ -732,7 +737,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise 60) { + userFacing.push(paragraph); + } else { + thinking.push(paragraph); + } + } return { thinking: thinking.join("\n\n"), - spoken: spoken.join("\n\n"), + spoken: userFacing.join("\n\n"), }; } diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index c0a64e9b..80a6284d 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -66,7 +66,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑 3. 事实只能来自用户原话;不得虚构或补全事件、日期、人物关系、动机、分盘、评分、候选或出生分钟。日期精度按用户真实表达保留。复述事件必须使用服务器返回的 display_date_label;禁止把日级说成“年份已确定为 YYYY”。用户确认“是 / 对”不得改 date_precision。 4. 工具只传最小引用。拒答和修订必须引用服务器返回且仍 active 的 focusId/evidenceId;用户对已有 pending 说“对/是”时可省略 focusId。无法唯一指向时只做简短澄清,不得猜测。 5. candidate、accepted、confirmed 严格分离。Agent 不控制 billing、ownership、profile 写入、不可逆状态,也不得授予 exact-minute confirmation。 -6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。 +6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。对用户说的话必须自己写在正文里,不要只写规划等服务器代写。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。 7. 只基于成功 attempt 输出正文。工具失败时说明面向用户的边界,不声称未执行的方法或结果。 8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。优先传 source 原文的 quoteStart/quoteEnd,不要改写 quote。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。 9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_action:id=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compare,C 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。挡住出牌的方法层未齐时,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 没有了 / 没了 / 没有其它 / 想不起来了 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–Dasha–Gochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。 diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index b324e841..25d4dac5 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -84,7 +84,7 @@ test("persisted rectification turns hydrate after the async Case refresh", () => assert.match(chat, /function messagesFromTurns\(initialTurns:/); assert.match(chat, /useState\(\(\) => messagesFromTurns\(initialTurns\)\)/); assert.match(chat, /finalizeRectificationSpokenAndThinking/); - assert.match(chat, /settleRectificationSpokenAndThinking/); + assert.doesNotMatch(chat, /settleRectificationSpokenAndThinking/); assert.match(chat, /text: split\.spoken,/); assert.doesNotMatch(chat, /split\.spoken \|\| raw/); assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/); @@ -238,12 +238,14 @@ test("usage completes or releases without hiding settlement failures", () => { assert.match(run, /billing\.complete\(/); assert.match(run, /billing\.release\(/); assert.match(run, /usage_settlement_failed/); - assert.match(run, /thinking: "disabled"/); + assert.match(run, /thinking: "enabled"/); assert.match(run, /answerTokens: 8_192/); + assert.match(run, /thinkingTokens: 8_192/); assert.match(run, /activity.changed/); - assert.match(run, /splitRectificationSpokenAndThinking/); + assert.match(run, /if \(!answerText\.trim\(\)\) \{[\s\S]*composeRectificationTurnNarration/); + assert.doesNotMatch(run, /splitRectificationSpokenAndThinking/); assert.match(run, /emit\(event: PublicStreamEvent\): Promise \| void;/); - assert.match(run, /agentGenerationSettings\(options\.generationModel, \{[\s\S]*thinking: "disabled"/); + assert.match(run, /agentGenerationSettings\(options\.generationModel, \{[\s\S]*thinking: "enabled"/); assert.match(route, /featureKey: "rectification"/); assert.match(route, /rectification:case:\$\{caseId\}/); }); @@ -321,6 +323,8 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr assert.match(chat, /回答未完成,已保留现有内容;本次不会扣点/); assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/); assert.doesNotMatch(chat, /event.type === "thinking.delta"/); + assert.doesNotMatch(chat, /thinkingText: settled\.thinking/); + assert.doesNotMatch(chat, /\.\.\.\(thinkingText \? \{ thinkingText \}/); assert.match(chat, /event.type === "activity.changed"/); assert.match(chat, /userFacingRunFailure/); assert.doesNotMatch(chat, /本轮处理未完成,已保留服务端记录的执行进度/); @@ -539,11 +543,17 @@ test("rectification keeps the composer but never renders generated suggestion ch }); test("does not parse suggestions from an incomplete run", () => { - assert.match(chat, /completed && !streamFailed \? parseAgentReply/); - assert.doesNotMatch(chat, /parseAgentReply\(raw\)/); + assert.match(chat, /completed && !streamFailed \? parseAgentReply\(raw\)/); assert.doesNotMatch(chat, /parseAgentReply\(partial/); }); +test("live answer.delta is the model reply, not a spoken-thinking split", () => { + assert.match(chat, /raw \+= event\.text/); + assert.match(chat, /text: raw,/); + assert.doesNotMatch(chat, /settleRectificationSpokenAndThinking/); + assert.doesNotMatch(chat, /text: settled\.spoken/); +}); + test("does not auto-submit a suggestion during render or recovery", () => { assert.match(chat, /origin: "typed"/); assert.match(chat, /origin: "choice_click"/); @@ -571,6 +581,7 @@ test("rectification Agent output stays natural and keeps tool execution silent", ); assert.match(agent, /工具执行过程保持静默/); assert.match(agent, /思考过程必须用简体中文/); + assert.match(agent, /对用户说的话必须自己写在正文里,不要只写规划等服务器代写/); assert.match(agent, /本轮做了什么/); assert.match(agent, /完成凭证完全由服务端公开 Activity\/receipt 展示/); assert.match(agent, /禁止只说记下了、会话会保留、以后再继续/); diff --git a/frontend/tests/rectification-spoken-answer.test.ts b/frontend/tests/rectification-spoken-answer.test.ts index e905aebb..66bd7813 100644 --- a/frontend/tests/rectification-spoken-answer.test.ts +++ b/frontend/tests/rectification-spoken-answer.test.ts @@ -1,3 +1,7 @@ +/** + * These helpers recover old leaked Turns on hydrate. They are not the live + * spoken-answer classifier; `runV9AgentTurn` must not call them. + */ import assert from "node:assert/strict"; import test from "node:test"; @@ -137,6 +141,57 @@ test("planning about candidate_contrast_packet stays out of the spoken answer", assert.doesNotMatch(settled.spoken, /candidate_contrast_packet/); }); +test("draft-ledger planning stays out of the spoken answer", () => { + const processTalk = [ + "第二件离职事件已记录为草稿。现在账本有两件已确认加一件草稿。", + "既然实习开始和实习离职都属于同一段职业经历并且相关联,用户后面的消息会继续补充,我先向用户确认这件离职信息,同时继续收集。", + "由于离职是事务性的独立事件且 quote 路径有障碍,我继续自然访谈。", + "先确认草稿再继续收集下一件。", + "这里草稿是服务器接受但待确认状态。", + "我应该继续访谈而不是纠缠草稿确认。", + ].join("\n\n"); + const spoken = [ + "好的,已经记下你这段职业经历:那年春天开始实习、秋天离职。加上之前上学,目前有了比较清晰的两大块时间线。", + "不用急着回忆全部。毕业后正式入职的第一份工作,大概哪一年?有没有恋爱、结婚这样的关系节点?哪一件印象深就先说哪件,大概年份就好。", + ].join("\n\n"); + + const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`); + assert.equal(split.spoken, spoken); + assert.match(split.thinking, /账本/); + assert.match(split.thinking, /我应该继续访谈/); + assert.match(split.thinking, /quote 路径/); + assert.doesNotMatch(split.spoken, /账本/); + assert.doesNotMatch(split.spoken, /我应该/); + assert.doesNotMatch(split.spoken, /草稿是服务器/); + assert.equal(isRectificationProcessNarration("我应该继续访谈而不是纠缠草稿确认。"), true); + assert.equal(isRectificationProcessNarration(spoken.split("\n\n")[0]!), false); +}); + +test("probe-deliberation with no user-facing close stays off the spoken answer", () => { + const processTalk = [ + "这回应的是当前探针(感情前事,用于对照 D9 差异)。", + "当前探针是请核对一段还没用进评分的感情前事。用户给出的是一个主观感受,不是具体带日期的事件。", + "让我看看是否应该把它作为一条证据记录。", + "严格来说,用户没有给出有确定日期的新事件。根据规则,没有具体带日期事件就按 focus 处理。", + "current_question 为 null,但 current_probe 有一条。", + "也许我应该直接自然回应,不调用工具。规则要求承接。", + "我认为这里不需要写证据。写入需要日期精度。", + "我直接自然问。", + ].join("\n\n"); + + const split = splitRectificationSpokenAndThinking(processTalk); + assert.equal(split.spoken, ""); + assert.match(split.thinking, /当前探针/); + assert.match(split.thinking, /current_question/); + assert.match(split.thinking, /我直接自然问/); + assert.equal(finalizeRectificationSpokenAndThinking(processTalk).spoken, ""); + + const settled = settleRectificationSpokenAndThinking(processTalk, ""); + assert.equal(settled.spoken, ""); + assert.doesNotMatch(settled.spoken, /探针/); + assert.doesNotMatch(settled.spoken, /根据规则/); +}); + test("leaked process text on the answer channel is not mixed into native thinking", () => { const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户"; const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?"; diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index 59476060..db7dc9c3 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -70,6 +70,7 @@ test("system prompt carries only high-priority boundaries, never the method copy assert.match(prompt, /禁止只说记下了/); assert.match(prompt, /工具执行过程保持静默/); assert.match(prompt, /思考过程必须用简体中文/); + assert.match(prompt, /对用户说的话必须自己写在正文里,不要只写规划等服务器代写/); assert.match(prompt, /skill_verification_report/); assert.match(prompt, /ask_candidate_discriminator/); assert.match(prompt, /方法覆盖已齐只进入候选区分/); @@ -308,8 +309,8 @@ test("server-loaded Skill is bound before the provider and the first model step assert.ok( ((await observedStreamOptions.prepareStep?.({ stepNumber: 1 })) as { activeTools?: string[] }).activeTools?.includes("rectification-read-case"), ); - assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 8192); - assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "disabled" } }); + assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 16_384); + assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "enabled" } }); assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1); assert.equal(emitted.some((event) => event.type === "run.completed"), true); assert.equal( diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index aadca64d..470b6ffe 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -1301,7 +1301,7 @@ test("does not publish intermediate tool-step text as answer.delta", async () => assert.equal(emitted.some((event) => event.type === "thinking.delta"), false); }); -test("Chinese interview planning in the terminal text-delta is not the spoken answer", async () => { +test("Chinese interview planning stays on reasoning-delta; the spoken answer is the model text-delta", async () => { const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**只要个大概年份就行。"; const { options, emitted } = runOptions({ buildAgent: async () => fakeAgentStream([ @@ -1312,16 +1312,14 @@ test("Chinese interview planning in the terminal text-delta is not the spoken an chunk("tool-result", { toolName: "rectification-read-case" }), chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }), chunk("tool-result", { toolName: "rectification-compare-candidates" }), - chunk("text-delta", { + chunk("reasoning-delta", { text: [ - "读取校正记录", - "比较候选时间", "方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分)。", "让我继续访谈,问一件能帮助区分候选的职业前事。", "本轮对照了Gochara、D1 本命盘、D10 事业分盘。", - spoken, ].join("\n\n"), }), + chunk("text-delta", { text: spoken }), chunk("finish"), ]) as never, }); @@ -1336,6 +1334,31 @@ test("Chinese interview planning in the terminal text-delta is not the spoken an assert.doesNotMatch(publicText, /occupation_note/); assert.doesNotMatch(publicText, /让我继续访谈/); assert.doesNotMatch(publicText, /本轮对照了/); + assert.equal(emitted.some((event) => event.type === "thinking.delta"), false); +}); + +test("model terminal text-delta is the reply even when Case narration could be composed", async () => { + const spoken = "职业已经记下。你入职大概是哪一年?说个年份就行。"; + 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("text-delta", { text: spoken }), + chunk("finish"), + ]) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.equal(result.answerText, spoken); + assert.deepEqual( + emitted.filter((event) => event.type === "answer.delta"), + [{ type: "answer.delta", text: spoken }], + ); + assert.doesNotMatch(result.answerText, /已经记下:/); + assert.doesNotMatch(result.answerText, /2016年9月离家去北京开始工作/); }); test("does not reset the whole attempt after duplicate_focus", async () => {