From a793edb1ad3ea6978c848dbf2564894555a6a6c2 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 4 Sep 2026 19:51:24 +0800 Subject: [PATCH] fix(rectification): keep opening greeting across set-focus set-focus only attaches the question stem. Retracting live Chinese before it wiped the opening hello with an empty replace. Other public tools still retract planning prose. BUG-533. Co-authored-by: Cursor --- CHANGELOG.md | 4 +++ docs/BUG_HISTORY.md | 16 +++++++++ .../rectification-agentic/v9/step-answer.ts | 15 ++++++-- .../tests/rectification-step-answer.test.ts | 36 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 430dce7f..f1380307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-04 — 开场打招呼不再被 set-focus 清掉 + +生时校正开场先播出的打招呼会留在同一条消息里。`rectification-set-focus` 只负责题干,不再发空的 `replace` 把正文抹掉。Skill 版本未变。 + ## 2026-09-04 — 未确认生时的个人报告不再被候选窗读取打死 出生时间尚未 confirmed 时,报告会去读校正候选窗。那张表已经收回运行时表权限,读失败却被当成计算不可用,整份报告在排盘前就失败。现在改走只读 RPC;读不到窗口就按无窗口继续生成。Skill 版本未变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5bef27b2..1e2fe35b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8228,3 +8228,19 @@ - 相关记录:无 - 复发自:无 - 修复版本:待发布 + +## BUG-533 | 开场打招呼被 set-focus 空 replace 清掉 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-04 +- 影响面:`applyStepAnswerChunk`、`retractSpoken`、开场轮 `answer.delta` +- 用户现象:开场先流出「你好,很高兴一起做这次生时校正…」,页面最终只剩「我们慢慢来就好…」和采集题干。 +- 触发条件:模型在 `rectification-set-focus` 之前写出带句号的中文正文;该步已 `live` 播出。 +- 根因:任一公开工具的 `tool-call` 都会 `retractLive`,发出 `answer.delta` `{text:"", replace:true}`。set-focus 只是把题干挂到同一条消息,前面的打招呼不是过程自述。 +- 修复:`rectification-set-focus` 的 call/result 不再收回已播出正文。读诊断、记证据仍收回。 +- 验证:`rectification-step-answer`:打招呼 + set-focus 为 `none`,其后短句仍 `live`;read-diagnostics 仍 `retract`。 +- 防复发:set-focus 不得把同轮已播出的正文 replace 成空。不得把这个例外扩到 record/compare/diagnostics。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/lib/rectification-agentic/v9/step-answer.ts b/frontend/src/lib/rectification-agentic/v9/step-answer.ts index b40e8f9f..45dc9309 100644 --- a/frontend/src/lib/rectification-agentic/v9/step-answer.ts +++ b/frontend/src/lib/rectification-agentic/v9/step-answer.ts @@ -6,7 +6,8 @@ * Once the current step looks like the spoken reply — Chinese sentence end, * or live-unlock after compare/offer/result tools — tokens may stream as * `live`. A later public tool-call retracts that speculative stream so - * "Let me set" never stays in `answer.delta`. + * "Let me set" never stays in `answer.delta`. Exception: `rectification-set-focus` + * only attaches the question stem; live greeting/handoff in the same step stays. */ export type StepAnswerChunk = Readonly<{ @@ -45,6 +46,11 @@ const SPOKEN_LIVE_UNLOCK_TOOLS = new Set([ "rectification-read-diagnostics", ]); +/** set-focus writes the stem onto the same turn; it is not planning prose. */ +const KEEP_LIVE_SPOKEN_TOOLS = new Set([ + "rectification-set-focus", +]); + function resetBuffers(state: StepAnswerState): void { state.text = ""; state.pieces = []; @@ -153,14 +159,19 @@ export function applyStepAnswerChunk( } case "tool-call": if (isPublicToolCall(chunk, isPublicTool)) { - const retracted = retractLive(state); state.calledTool = true; + if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) return { kind: "none" }; + const retracted = retractLive(state); return retracted.kind === "retract" ? retracted : { kind: "none" }; } return { kind: "none" }; case "tool-result": case "tool-error": { if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true; + if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) { + resetBuffers(state); + return { kind: "none" }; + } const discarded = state.calledTool || state.text.length > 0; const retracted = retractLive(state); resetBuffers(state); diff --git a/frontend/tests/rectification-step-answer.test.ts b/frontend/tests/rectification-step-answer.test.ts index 3b9d5a52..0fcc2648 100644 --- a/frontend/tests/rectification-step-answer.test.ts +++ b/frontend/tests/rectification-step-answer.test.ts @@ -106,6 +106,42 @@ test("retracts a live spoken prefix if that step later calls a public tool", () assert.deepEqual(spoken, { kind: "live", text: "诊断已经看过。接下来确认入职年份。" }); }); +test("keeps a live opening greeting when the next public tool is set-focus", () => { + const state = createStepAnswerState(); + assert.deepEqual( + applyStepAnswerChunk( + state, + chunk("text-delta", { text: "你好,我们从你最容易想起的经历开始就行。" }), + isPublicTool, + ), + { kind: "live", text: "你好,我们从你最容易想起的经历开始就行。" }, + ); + assert.equal( + applyStepAnswerChunk( + state, + chunk("tool-call", { toolName: "rectification-set-focus" }), + isPublicTool, + ).kind, + "none", + ); + assert.equal( + applyStepAnswerChunk( + state, + chunk("tool-result", { toolName: "rectification-set-focus" }), + isPublicTool, + ).kind, + "none", + ); + assert.deepEqual( + applyStepAnswerChunk( + state, + chunk("text-delta", { text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" }), + isPublicTool, + ), + { kind: "live", text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" }, + ); +}); + test("does not live-publish English planning before a tool-call", () => { const state = createStepAnswerState(); assert.equal(