diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3d93cc..bb0e283b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-18 — 咨询正文按句出现,不再整段蹦出来 + +回答重新按句子往外长,不用等整段写完。没有出生分钟时,一般知识句会留下来,只有「你的盘」那类句子被拿掉;全部都不能用时才改口说需要具体分钟。Skill 版本不变。 + ## 2026-09-18 — 出生范围用户的应期不再被删字 只知道一段出生范围时,大运和行运边界按区间说出来,不再挖成「[具体时间已省略]」。没有出生分钟时,个人星盘断言会整段换成需要具体分钟的说明。保证性结论(一定会、注定)仍不发。多领域问题装不下的部分记在省略列表里,不再整轮算不成。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e47775dc..bbd946d5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -12445,3 +12445,67 @@ - 相关记录:BUG-732、BUG-943 - 复发自:无 - 修复版本:`e32ce624` + +## BUG-950 | Pass 4 整段 hold,正文不再逐字出现 + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`stream-agent-response.ts`、`timing-output-guard.ts` +- 用户现象:本命 / 窗口 / 一般咨询的正文整段一次性蹦出来,打字机没了,首字延迟等于整段写完。 +- 触发条件:`pass4Mode` 三条路径全开;三个 `text-delta` 只产出 1 条 `answer.delta`。 +- 根因:`holdAnswer` 把整段攒进 `fullOutput`,等 `finishPass4` 判定完一次发出。948 让步「不闪两次」等于关掉流式。 +- 修复:只缓冲当前未闭合句。句子碰到 `。!?.!?\n` 就对这一句跑 `classifyPass4`:无 `reject` 立刻 `send`;有 `reject` 整句不发。`exact-timing` 的 `observe` 不阻塞。`methodology` 整篇重写只允许发生在还没发出任何正文之前;已经发出过句子就只丢后续命中句。流末残句再过一次门。 +- 验证:三个 `text-delta` 在 `verified_chart` 下 ≥3 条 `answer.delta`;保证句不出现在任何 `answer.delta` 且回执有 `pass4-reject:guarantee`;日期句照发且有 `pass4-observe:exact-timing`。 +- 防复发:Pass 4 是句门不是刀,不得再整段 hold。合同锁 ≥3 条 delta。 +- 相关记录:BUG-948 +- 复发自:BUG-948 +- 修复版本:待合入 SHA + +## BUG-951 | 没有出生分钟时,一句拒绝顶掉整段回答 + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`timing-output-guard.ts`、`stream-agent-response.ts` +- 用户现象:一般知识句和个人盘断言写在同一段时,整段被换成「这部分需要具体出生分钟才能判断」。 +- 触发条件:`general_no_birth_time` 二次落地;无 `composeAnswer` 的一般路径没有重写机会。 +- 根因:二次命中 `personal-chart` 时整段替换成 `GENERAL_NO_BIRTH_TIME_REFUSAL`。旧实现是按句挖掉。948 任务书「兜底句替代整段」措辞造成。 +- 修复:`dropRejectedClauses` 只丢 `guarantee` / `personal-chart` / `methodology` 命中句。其余原样。只有全部句子都丢掉、正文为空时才发兜底句。保证句静默删句,回执留 `pass4-reject:guarantee`。 +- 验证:混合文本二次后一般知识句仍在,个人盘句与保证句不在,无残留 `。。`;全丢用例仍落到兜底句。 +- 防复发:不得再对 `general_no_birth_time` 二次整段替换;合同锁「preserving general knowledge」。 +- 相关记录:BUG-948、BUG-950 +- 复发自:BUG-948 +- 修复版本:待合入 SHA + +## BUG-952 | 没有出生时间时的具体日期既不记录也不拦 + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`timing-output-guard.ts` +- 用户现象:无出生分钟模式下写了具体日期,回执没有任何痕迹。 +- 触发条件:`classifyPass4` 对 `exact-timing` 在 `general_no_birth_time` 直接 `continue`。 +- 根因:948 让步 4 把「日期不删字」写成「连 observe 也不记」,恰恰在最没有依据给日期的模式下一笔痕迹都没有。 +- 修复:该模式下 `exact-timing` 记 `observe`,仍不拦、不删字。 +- 验证:含日期正文原样通过,回执有 `pass4-observe:exact-timing`。 +- 防复发:`exact-timing` 在所有 `pass4Mode` 下都要能观察到。 +- 相关记录:BUG-948、BUG-950 +- 复发自:BUG-948 +- 修复版本:待合入 SHA + +## BUG-953 | 校正流 token 级 thinking 是死链,按 P2 删除 + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`rectification-agentic/v9/stream-mapping.ts`、`think-step-gate.ts` +- 用户现象:无(用户看不见这条通道)。947 修好的是测试里的死函数。 +- 触发条件:生产校正流对 `reasoning-delta` 在 `step-answer.ts` 直接 `{ kind: "none" }`。`toPublicThinkingDelta` / `mapStreamChunkToThinking` 零生产调用方。 +- 根因:上游 P2 规定 provider reasoning 永不外发。把 reasoning 分片推给浏览器本身就违反 P2。947 按任务书方案 (b) 留了这条没人走的路。 +- 修复:删除 `toPublicThinkingDelta`、`mapStreamChunkToThinking`、`InternalThinkingDeltaEvent`、`acceptThinkingFragment`、`createThinkingFragmentAssembler`。`acceptThinkStepText` 保留。测试翻转成否定合同:源码不得出现这些函数名,`reasoning-delta` 必须丢弃。 +- 验证:全仓 `frontend/src` grep 不到上述函数;相关测试翻转后仍绿,测试总数不降。 +- 防复发:校正流不得再把 `reasoning-delta` 映射成对外事件。将来校正面板若要显示判断依据,走咨询流 Pass 2 `think.step`。 +- 相关记录:BUG-947、BUG-942 +- 复发自:BUG-947 +- 修复版本:待合入 SHA diff --git a/docs/tasks/PROGRESS-consult-pass4-streaming-20260918.md b/docs/tasks/PROGRESS-consult-pass4-streaming-20260918.md new file mode 100644 index 00000000..734175cc --- /dev/null +++ b/docs/tasks/PROGRESS-consult-pass4-streaming-20260918.md @@ -0,0 +1,45 @@ +# PROGRESS · Pass 4 按句放行与思考死链清理(2026-09-18 第三轮) + +工作树:`.worktrees/consult-pass4-streaming-20260918` +分支:`codex/consult-pass4-streaming-20260918` +基线:`origin/staging` @ `24a6fc98`(任务书写 `1e553976`,其后一笔纯文档已快进) +任务书:`docs/tasks/TASK-consult-pass4-streaming-20260918.md` + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| BUG-950 按句放行 | 完成 | 只缓冲当前句;无 reject 立刻 send;observe 不阻塞 | +| BUG-951 按句丢弃 | 完成 | `dropRejectedClauses`;全丢才用兜底句 | +| BUG-952 无分钟日期 observe | 完成 | `general_no_birth_time` 下 exact-timing 记 observe | +| BUG-953 删除 token 级 thinking | 完成 | 死链删除,测试翻转成否定合同 | + +## 950 / 951 分界 + +Pass 4 是句门不是刀。闭合句整句发或整句不发,不对句内动刀。整篇重写(methodology,或 compose 在一句都还没发出时的退回)只允许发生在第一条 `answer.delta` 之前;已经发出过句子,后续命中句只丢不撤。这就是「不闪两次」和「要流式」的分界,写在 `stream-agent-response.ts` 注释里。 + +## 断言三栏(既有测试) + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `pass4 holds verified dates…` | 1 条 `answer.delta` | ≥3 条,日期句照发 + observe | BUG-950 按句放行 | +| `pass4 retries compose once…` | compose 2 次,整段 hold 后一次发出 | compose 1 次;保证句不在任何 delta 里 | 已发出过句子不再整篇重写 | +| `pass4 general mode second-pass replaces…` | 整段换成拒绝句 | 知识句发出,个人盘句不发,不出现兜底句 | BUG-951 按句丢弃 | +| `general mode … preserving general knowledge` | 二次整段 `GENERAL_NO_BIRTH_TIME_REFUSAL` | 知识句仍在,个人盘/保证句不在,无 `。。` | 恢复被 948 改掉的口径 | +| `output guards … stay byte-stable` 混合文本 | 二次整段拒绝 | 知识句保留,个人盘句丢掉 | 同上 | +| `never publishes reasoning-delta` | `mapStreamChunkToThinking` 能变成 thinking 行 | 源码不得有该函数;运行时丢弃 | BUG-953 否定合同 | +| `consecutive Chinese reasoning fragments…` | assembler 拼出可见行 | assembler 删除;连续分片仍丢弃 | 同上,测试不删只翻转 | +| `Chinese thinking stays internal…` | 内部 thinking.delta 对象 | 源码不得映射;`safePublicEvent` 仍丢 | 同上 | + +## 测试 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(120 warning,基线 121 量级) | +| 定向 | timing-output-guard / birth-time-mode / birth-accuracy / agentic-runtime Pass 4 / rectification-step-answer / v9-stream 全绿(Windows 上 skill symlink EPERM 两条与基线相同,不是本单) | +| 全量 `npm test`(本机 Windows) | tests 3453 / pass 3375 / fail 78。较上一轮 Windows 实测 3449 / 3371 / 78 **+4 条绿、失败数不变**。78 条全是 symlink/EPERM、bash、无 Docker 的既有缺口,清单与上一轮同组 | +| Linux 门禁目标 | 基线 3491 / 3445 / 31;本单 +4 条,预期 3495 / 3449 / 31 | +| `next build --webpack` | 编译成功(71s)。收集 page data 时 `/api/daily-starlanguage` 撞 skill symlink EPERM,与测试同一环境缺口。改动全在服务端 `stream-agent-response` / `timing-output-guard` / 校正 mapping,不进 `/` 客户端包;`/` Static 与 468,388 B gzip 留给 Linux 门禁 | + +本单新增:三 `text-delta` ≥3 条 delta;全丢才兜底;无分钟日期 observe 流式合同。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index c1e18b73..d45c4a3d 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -137,7 +137,7 @@ | — | `PROGRESS-birth-time-journey-page-path-20260918.md` | **P0 门禁**:`test_birth_time_journey_contract` 仍读已搬走的 `app/page.tsx`(BUG-939);修好后 run 2764 又露出 5 条校正守卫后的前端源码合同(BUG-940)。均复发自 BUG-933 | 已合入 | `39a0d7a9` / `0378d9e0` | | `TASK-consult-three-channels-20260918.md` | `PROGRESS-consult-three-channels-20260918.md` | 咨询运行时三通道:进度 / 思考 / 正文生成时分离。设计分支误用 BUG-939/940/941,落地改号 942–944 | 验收未通过(见修复单) | `94c1e81f` | | `TASK-consult-three-channels-fix-20260918.md` | `PROGRESS-consult-three-channels-fix-20260918.md` | 验收修复单:领域上限解耦(945)、31s 预算断言(946)、校正思考分片门(947)、Pass 4 按模式分流(948)、容量算术(949) | 已验收通过(Claude:tsc 0 / lint 0 error / npm test 3491 条 31 红且与基线 `742ffbc6` 同一组、原 12 条红全绿 / `/` 仍 Static / 首屏 js gzip 468,388 B 较基线 +0.02%);review 另出 BUG-950~953 见下一行 | `e32ce624` | -| `TASK-consult-pass4-streaming-20260918.md` | — | 验收 review 三轮:Pass 4 一 hold 正文就整段蹦出、逐字流式消失(BUG-950 产品拍板按句放行);无出生分钟模式整段被一句拒绝顶掉、一般知识句一起丢(951 改按句丢弃);该模式下日期不留痕(952);校正流 token 级 thinking 是死链,按 P2 删除并把测试翻转成否定合同(953)。基线 `1e553976` | 待领取 | — | +| `TASK-consult-pass4-streaming-20260918.md` | `PROGRESS-consult-pass4-streaming-20260918.md` | 验收 review 三轮:Pass 4 一 hold 正文就整段蹦出、逐字流式消失(BUG-950 产品拍板按句放行);无出生分钟模式整段被一句拒绝顶掉、一般知识句一起丢(951 改按句丢弃);该模式下日期不留痕(952);校正流 token 级 thinking 是死链,按 P2 删除并把测试翻转成否定合同(953)。基线 `1e553976` | 待验收 | `codex/consult-pass4-streaming-20260918` | | `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — | | `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` | | `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 | diff --git a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts index 9d2b6436..a1820c9a 100644 --- a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts +++ b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts @@ -22,7 +22,6 @@ import { type PublicRectificationPhase, type PublicRectificationTool, } from "./public-receipt"; -import { acceptThinkingFragment } from "../../think-step-gate"; import { isToolInputRejection, toolResultFromChunk } from "./host-fallback"; export type PublicPhaseStreamEvent = Readonly<{ @@ -176,28 +175,11 @@ export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicPhaseStreamE return null; default: // Raw reasoning, payloads, step internals and provider metadata stay off - // the answer channel. Chinese thinking is mapped separately. + // the public stream. Provider CoT is never mapped to an outward event. return null; } } -export type InternalThinkingDeltaEvent = Readonly<{ - type: "thinking.delta"; - text: string; -}>; - -export function toPublicThinkingDelta(text: string): InternalThinkingDeltaEvent | null { - const cleaned = acceptThinkingFragment(text); - if (!cleaned) return null; - return { type: "thinking.delta", text: cleaned }; -} - -export function mapStreamChunkToThinking(chunk: AgentChunkType): InternalThinkingDeltaEvent | null { - if (chunk.type !== "reasoning-delta") return null; - const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : ""; - return toPublicThinkingDelta(text); -} - /** * Project real public tool lifecycle events for the live UI. This stream is * deliberately separate from the durable phase receipt: it never exposes diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index f8cd84c8..4b2d1a2a 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -14,7 +14,14 @@ import { createVisibleTextTransformer } from "./stream-text-response.ts"; import { consultationWriteLabel } from "./consultation-activity-labels.ts"; import { logTruncatedReasoning } from "./consultation-budget.ts"; import { acceptThinkStepText } from "./think-step-gate.ts"; -import { applyPass4Policy, type Pass4Mode } from "./timing-output-guard.ts"; +import { + classifyPass4, + takeClosedSentences, + GENERAL_NO_BIRTH_TIME_REFUSAL, + PASS4_RETRY_HINT, + type Pass4Mode, + type Pass4Step, +} from "./timing-output-guard.ts"; import { applyThinkingSectionProgress, generalConsultationThinkingPlan, @@ -346,6 +353,13 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { let fullOutput = ""; let thinkingText = ""; let planSent = false; + // Pass 4 buffers only the current open sentence. Closed sentences are + // classified and either sent whole or dropped whole. Whole-answer rewrite + // is allowed only before any answer.delta has gone out; after the first + // sentence is public, later rejects are dropped in place so the user never + // sees a flash-then-replace. That is the boundary between "don't flash + // twice" and "stream by sentence". + let pass4Buffer = ""; const startedAt = new Map(); // A retry reuses these counters so a failure in either attempt is recorded once. const toolErrors = { seen: 0 }; @@ -370,10 +384,51 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { for (const event of planned) send(controller, event); }; + const pendingAnswer = () => fullOutput + pass4Buffer; + + function recordPass4Steps(steps: readonly Pass4Step[]) { + for (const step of steps) { + const name = `${step.action === "observe" ? "pass4-observe" : "pass4-reject"}:${step.kind}`; + if (options.state.steps.some((item) => item.name === name)) continue; + appendConsultationRuntimeStep(options.state, { + kind: "validation", + name, + status: step.action === "observe" ? "completed" : "failed", + failureCode: step.kind, + }); + } + } + + async function releasePass4Sentences( + controller: ReadableStreamDefaultController | undefined, + text: string, + flush: boolean, + ) { + if (!options.pass4Mode) return; + pass4Buffer += text; + const { closed, rest } = flush + ? { closed: pass4Buffer ? [pass4Buffer] : [], rest: "" } + : takeClosedSentences(pass4Buffer); + pass4Buffer = rest; + for (const sentence of closed) { + if (!sentence) continue; + const steps = classifyPass4(sentence, options.pass4Mode); + recordPass4Steps(steps); + if (steps.some((step) => step.action === "reject")) continue; + if (!firstOutput && /\S/.test(sentence)) { + firstOutput = true; + await options.onFirstOutput?.(); + } + send(controller, { type: "answer.delta", text: sentence }); + fullOutput += sentence; + if (/\S/.test(sentence)) emitted = true; + } + } + async function consumeAttempt( controller: ReadableStreamDefaultController | undefined, stream: ChunkStream, - attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean; holdAnswer?: boolean } = {}, + attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean } = {}, ) { const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value)); let held = ""; @@ -392,11 +447,16 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { composingSent = true; send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" }); } + if (options.pass4Mode) { + await releasePass4Sentences(controller, held, false); + held = ""; + return; + } if (!firstOutput && /\S/.test(held)) { firstOutput = true; await options.onFirstOutput?.(); } - if (!attempt.holdAnswer) send(controller, { type: "answer.delta", text: held }); + send(controller, { type: "answer.delta", text: held }); fullOutput += held; if (/\S/.test(held)) emitted = true; held = ""; @@ -456,23 +516,21 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { async function continueCurrentAnswer( controller: ReadableStreamDefaultController | undefined, heading?: string, - holdAnswer = false, ) { if (options.state.modelFinishReason !== "length") return; if (!options.continueAfterLength) throw new Error("answer_truncated"); - const beforeContinue = fullOutput; + const beforeContinue = pendingAnswer(); appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-continue", status: "completed" }); send(controller, { type: "activity", phase: "answer-composition", label: heading ? consultationWriteLabel(heading, true) : "正在组织回答", }); - await consumeAttempt(controller, await options.continueAfterLength(fullOutput), { + await consumeAttempt(controller, await options.continueAfterLength(pendingAnswer()), { suppressCompositionActivity: true, - holdAnswer, }); - if (!/\S/.test(fullOutput)) throw new Error("empty_answer"); - if (options.state.modelFinishReason === "length" && fullOutput === beforeContinue) { + if (!/\S/.test(pendingAnswer())) throw new Error("empty_answer"); + if (options.state.modelFinishReason === "length" && pendingAnswer() === beforeContinue) { throw new Error("answer_truncated"); } } @@ -512,49 +570,40 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { return findings; } - function recordPass4Steps(steps: ReturnType["steps"]) { - const seen = new Set(); - for (const step of steps) { - const key = `${step.action}:${step.kind}`; - if (seen.has(key)) continue; - seen.add(key); - appendConsultationRuntimeStep(options.state, { - kind: "validation", - name: `${step.action === "observe" ? "pass4-observe" : "pass4-reject"}:${step.kind}`, - status: step.action === "observe" ? "completed" : "failed", - failureCode: step.kind, - }); - } - } - async function finishPass4( controller: ReadableStreamDefaultController | undefined, origin: string, findings: readonly ThinkFinding[], + allowComposeRetry = true, ) { - if (!options.pass4Mode) { - const produced = fullOutput.slice(origin.length); - if (produced) send(controller, { type: "answer.delta", text: produced }); - return; - } - let report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode); - recordPass4Steps(report.steps); - if (report.retry && options.composeAnswer) { + if (!options.pass4Mode) return; + await releasePass4Sentences(controller, "", true); + const produced = () => fullOutput.slice(origin.length); + const hadRetryableReject = options.state.steps.some((step) => + step.name === "pass4-reject:guarantee" + || step.name === "pass4-reject:personal-chart" + || step.name === "pass4-reject:methodology" + ); + if (allowComposeRetry && !/\S/.test(produced()) && hadRetryableReject && options.composeAnswer) { fullOutput = origin; + pass4Buffer = ""; await consumeAttempt( controller, - await options.composeAnswer(findings, report.retryHint), - { suppressCompositionActivity: true, holdAnswer: true }, + await options.composeAnswer(findings, PASS4_RETRY_HINT), + { suppressCompositionActivity: true }, ); - await continueCurrentAnswer(controller, undefined, true); - report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode, { secondPass: true }); - recordPass4Steps(report.steps); - } else if (report.retry) { - report = applyPass4Policy(fullOutput.slice(origin.length), options.pass4Mode, { secondPass: true }); - recordPass4Steps(report.steps); + await continueCurrentAnswer(controller); + await releasePass4Sentences(controller, "", true); + } + if (!/\S/.test(produced()) && options.pass4Mode === "general_no_birth_time" && hadRetryableReject) { + if (!firstOutput) { + firstOutput = true; + await options.onFirstOutput?.(); + } + send(controller, { type: "answer.delta", text: GENERAL_NO_BIRTH_TIME_REFUSAL }); + fullOutput = origin + GENERAL_NO_BIRTH_TIME_REFUSAL; + emitted = true; } - fullOutput = origin + report.text; - if (report.text) send(controller, { type: "answer.delta", text: report.text }); } async function composeOnce( @@ -577,9 +626,9 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { await consumeAttempt( controller, await options.composeAnswer(findings), - { suppressCompositionActivity: true, holdAnswer: Boolean(options.pass4Mode) }, + { suppressCompositionActivity: true }, ); - await continueCurrentAnswer(controller, undefined, Boolean(options.pass4Mode)); + await continueCurrentAnswer(controller); await finishPass4(controller, origin, findings); send(controller, { type: "phase.completed", @@ -602,32 +651,31 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { for (const event of skillBoundEvents) send(controller, event); flushThinkingPlan(controller); try { - const holdMain = Boolean(options.pass4Mode) && !options.composeAnswer; await consumeAttempt(controller, options.stream, { drainSpoken: Boolean(options.composeAnswer), - holdAnswer: holdMain, }); if (!contractReady(options) && options.retry) { appendConsultationRuntimeStep(options.state, { kind: "validation", name: "runtime-contract-retry", status: "completed" }); send(controller, { type: "activity", phase: "loading-method", label: "正在补齐方法与计算步骤" }); await consumeAttempt(controller, await options.retry(), { drainSpoken: Boolean(options.composeAnswer), - holdAnswer: holdMain, }); } if (!contractReady(options)) throw new Error("runtime_contract_incomplete"); const findings = await publishFindings(controller); const composed = await composeOnce(controller, findings); + if (!composed) { + await continueCurrentAnswer(controller); + if (options.pass4Mode) await finishPass4(controller, "", findings); + } if (!/\S/.test(fullOutput) && options.retryForAnswer) { appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" }); send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" }); - await consumeAttempt(controller, await options.retryForAnswer(), { holdAnswer: holdMain }); + const retryOrigin = fullOutput; + await consumeAttempt(controller, await options.retryForAnswer()); + if (options.pass4Mode) await finishPass4(controller, retryOrigin, findings, false); } if (!/\S/.test(fullOutput)) throw new Error("empty_answer"); - if (!composed) { - await continueCurrentAnswer(controller, undefined, holdMain); - if (holdMain) await finishPass4(controller, "", findings); - } settling = true; const receipt = agentExecutionReceiptSchema.parse(options.receipt()); const thinkingSections = applyThinkingSectionProgress(options.state.thinkingPlan ?? [], fullOutput); diff --git a/frontend/src/lib/think-step-gate.ts b/frontend/src/lib/think-step-gate.ts index 3b35a586..a2990a71 100644 --- a/frontend/src/lib/think-step-gate.ts +++ b/frontend/src/lib/think-step-gate.ts @@ -15,37 +15,3 @@ export function acceptThinkStepText(text: string): string | null { if (!SENTENCE_RE.test(trimmed)) return null; return trimmed; } - -const TOOLISH_FRAGMENT_RE = /(?:rectification|run-jyotish)-[a-z0-9-]+|skill_read|proposedKind|validationErrors/i; - -/** - * Rectification still publishes reasoning as fragments. This gate only accepts - * or rejects a chunk. It does not rewrite, strip English, or require 8 chars. - */ -export function acceptThinkingFragment(text: string): string | null { - const trimmed = text.replace(/\s+/g, " ").trim(); - if (!trimmed) return null; - if (!CJK_RE.test(trimmed)) return null; - if (TOOLISH_FRAGMENT_RE.test(trimmed)) return null; - return trimmed; -} - -export function createThinkingFragmentAssembler() { - let buffer = ""; - return { - push(chunk: string): string | null { - const accepted = acceptThinkingFragment(chunk); - if (!accepted) return null; - buffer += accepted; - if (!SENTENCE_RE.test(buffer)) return null; - const released = buffer; - buffer = ""; - return released; - }, - flush(): string | null { - const leftover = buffer; - buffer = ""; - return leftover ? acceptThinkingFragment(leftover) : null; - }, - }; -} diff --git a/frontend/src/lib/timing-output-guard.ts b/frontend/src/lib/timing-output-guard.ts index 9e68b8f4..25411456 100644 --- a/frontend/src/lib/timing-output-guard.ts +++ b/frontend/src/lib/timing-output-guard.ts @@ -162,14 +162,33 @@ export type Pass4Result = Readonly<{ retryHint?: string; }>; -function dropGuaranteeClauses(text: string): string { - const parts = text.split(/([。!?.!?\n]+)/u); +const SENTENCE_TERMINATOR = /[。!?.!?\n]/u; + +/** + * Pass 4 is a sentence gate, not a rewrite knife. A closed sentence is sent + * whole or dropped whole; nothing inside a sentence is rewritten. + */ +export function takeClosedSentences(buffer: string): { closed: string[]; rest: string } { + const closed: string[] = []; + let cursor = 0; + for (let index = 0; index < buffer.length; index += 1) { + if (!SENTENCE_TERMINATOR.test(buffer[index]!)) continue; + let end = index + 1; + while (end < buffer.length && SENTENCE_TERMINATOR.test(buffer[end]!)) end += 1; + closed.push(buffer.slice(cursor, end)); + cursor = end; + index = end - 1; + } + return { closed, rest: buffer.slice(cursor) }; +} + +function dropRejectedClauses(text: string, mode: Pass4Mode): string { + const { closed, rest } = takeClosedSentences(text); + const pieces = rest ? [...closed, rest] : closed; let output = ""; - for (let index = 0; index < parts.length; index += 2) { - const clause = parts[index] ?? ""; - const punct = parts[index + 1] ?? ""; - if (detectPreciseTimingViolations(clause).some((item) => item.kind === "guarantee")) continue; - output += clause + punct; + for (const sentence of pieces) { + if (classifyPass4(sentence, mode).some((step) => step.action === "reject")) continue; + output += sentence; } return output; } @@ -181,9 +200,7 @@ export function classifyPass4( const steps: Pass4Step[] = []; for (const hit of detectPreciseTimingViolations(text)) { if (hit.kind === "exact-timing") { - if (mode !== "general_no_birth_time") { - steps.push({ action: "observe", kind: hit.kind, excerpt: hit.excerpt }); - } + steps.push({ action: "observe", kind: hit.kind, excerpt: hit.excerpt }); continue; } if (hit.kind === "guarantee") { @@ -203,6 +220,13 @@ export function classifyPass4( return steps; } +export const PASS4_RETRY_HINT = + "不要写保证性结论(一定、保证、注定、will definitely)。无出生分钟时不要对用户作个人星盘断言。不要在正文写统一参数或技法审计表。"; + +function isRetryableReject(kind: TimingGuardKind) { + return kind === "guarantee" || kind === "personal-chart" || kind === "methodology"; +} + export function applyPass4Policy( text: string, mode: Pass4Mode, @@ -210,20 +234,20 @@ export function applyPass4Policy( ): Pass4Result { const steps = classifyPass4(text, mode); const rejects = steps.filter((step) => step.action === "reject"); - if (!options?.secondPass && rejects.some((step) => step.kind === "guarantee" || step.kind === "personal-chart")) { + if (!options?.secondPass && rejects.some((step) => isRetryableReject(step.kind))) { return { text, steps, retry: true, - retryHint: "不要写保证性结论(一定、保证、注定、will definitely)。无出生分钟时不要对用户作个人星盘断言。", + retryHint: PASS4_RETRY_HINT, }; } let next = text; - if (options?.secondPass && rejects.some((step) => step.kind === "guarantee")) { - next = dropGuaranteeClauses(next); - } - if (options?.secondPass && mode === "general_no_birth_time" && rejects.some((step) => step.kind === "personal-chart")) { - next = GENERAL_NO_BIRTH_TIME_REFUSAL; + if (options?.secondPass && rejects.some((step) => isRetryableReject(step.kind))) { + next = dropRejectedClauses(next, mode); + if (!/\S/.test(next) && mode === "general_no_birth_time") { + next = GENERAL_NO_BIRTH_TIME_REFUSAL; + } } return { text: next, steps, retry: false }; } diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index d087f44b..262e1912 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -1702,10 +1702,15 @@ test("composeAnswer length continue finishes the same body", async () => { }); test("pass4 holds verified dates and records pass4-observe without rewriting", async () => { + // 原值:三个日期句 hold 成 1 条 answer.delta + // 新值:每句闭合即发,≥3 条 answer.delta,日期不改写 + // 原因:BUG-950 按句放行;exact-timing 是 observe,不得阻塞发送。 const state = toolOnlyRunState(); async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; - yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日 至 2031年11月22日。" } }; + yield { type: "text-delta", payload: { text: "第一句先说方向。" } }; + yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日。" } }; + yield { type: "text-delta", payload: { text: "第三句把区间说完。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ @@ -1719,13 +1724,16 @@ test("pass4 holds verified dates and records pass4-observe without rewriting", a const answers = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text); - assert.equal(answers.length, 1); - assert.match(answers[0] ?? "", /2013年11月21日/); + assert.ok(answers.length >= 3, `expected ≥3 answer.delta, got ${answers.length}`); + assert.match(answers.join(""), /2013年11月21日/); assert.doesNotMatch(answers.join(""), /具体时间已省略/); assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true); }); test("pass4 retries compose once on guarantee then drops leftover clauses", async () => { + // 原值:整段 hold,compose 两次后一次发出 + // 新值:按句放行,保证句从不出现在任何 answer.delta;已发出过句子不再整篇重写 + // 原因:BUG-950,「不闪两次」只约束已发出的不撤回。 const state = toolOnlyRunState(); async function* first() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; @@ -1736,16 +1744,12 @@ test("pass4 retries compose once on guarantee then drops leftover clauses", asyn runId: "run", requestId: "req", state, stream: first(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "ready", receipt: () => receipt(state), - composeAnswer: async (_findings, retryHint) => { + composeAnswer: async () => { composed += 1; - if (composed === 2) assert.match(retryHint ?? "", /不要写保证性结论/); async function* body() { - yield { - type: "text-delta", - payload: { - text: "方向可以推进。我保证你一定会升职。", - }, - }; + yield { type: "text-delta", payload: { text: "方向可以推进。" } }; + yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } }; + yield { type: "text-delta", payload: { text: "第三句照常。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return body(); @@ -1754,17 +1758,70 @@ test("pass4 retries compose once on guarantee then drops leftover clauses", asyn const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); - assert.equal(composed, 2); - const answer = events + assert.equal(composed, 1); + const answers = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") - .map((event) => event.text) - .join(""); - assert.match(answer, /方向可以推进/); - assert.doesNotMatch(answer, /一定会升职/); + .map((event) => event.text); + assert.ok(answers.length >= 2, `expected ≥2 answer.delta, got ${answers.length}`); + assert.equal(answers.some((text) => /一定会升职|我保证/.test(text)), false); + assert.match(answers.join(""), /方向可以推进/); + assert.match(answers.join(""), /第三句照常/); assert.equal(state.steps.some((step) => step.name === "pass4-reject:guarantee"), true); }); test("pass4 general mode second-pass replaces personal chart claims with the refusal", async () => { + // 原值:一句个人盘断言把整段换成拒绝句 + // 新值:混合文本按句丢弃,知识句发出,个人盘句不发;全丢才用兜底句 + // 原因:BUG-951。 + const state = createConsultationRuntimeState(); + state.jyotishSkillBound = true; + async function* chunks() { + yield { type: "text-delta", payload: { text: "第七宫在占星概念中常与关系相关。" } }; + yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } }; + yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: false, + pass4Mode: "general_no_birth_time", + toolStatus: () => "ready", receipt: () => receipt(state), + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + const answers = events + .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") + .map((event) => event.text); + assert.match(answers.join(""), /第七宫在占星概念中常与关系相关/); + assert.equal(answers.some((text) => /你的上升是巨蟹座/.test(text)), false); + assert.doesNotMatch(answers.join(""), new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL)); + assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true); +}); + +test("pass4 releases each closed sentence and never emits a rejected clause", async () => { + const state = toolOnlyRunState(); + async function* chunks() { + yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; + yield { type: "text-delta", payload: { text: "第一句话。" } }; + yield { type: "text-delta", payload: { text: "第二句话。" } }; + yield { type: "text-delta", payload: { text: "第三句话。" } }; + yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + pass4Mode: "verified_chart", + toolStatus: () => "ready", receipt: () => receipt(state), + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + const answers = events + .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") + .map((event) => event.text); + assert.ok(answers.length >= 3, `expected ≥3 answer.delta, got ${answers.length}`); + assert.equal(answers.join(""), "第一句话。第二句话。第三句话。"); +}); + +test("pass4 general mode uses the refusal only after every sentence is dropped", async () => { const state = createConsultationRuntimeState(); state.jyotishSkillBound = true; async function* chunks() { @@ -1787,6 +1844,28 @@ test("pass4 general mode second-pass replaces personal chart claims with the ref assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true); }); +test("pass4 general mode observes dates while streaming the sentence", async () => { + const state = createConsultationRuntimeState(); + state.jyotishSkillBound = true; + async function* chunks() { + yield { type: "text-delta", payload: { text: "2026年8月适合观察方向。" } }; + yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: false, + pass4Mode: "general_no_birth_time", + toolStatus: () => "ready", receipt: () => receipt(state), + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + const answers = events + .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") + .map((event) => event.text); + assert.match(answers.join(""), /2026年8月适合观察方向/); + assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true); +}); + test("natal tool success stores a Chinese thinking plan", async () => { const { state } = await runDomainPlan(["career", "wealth"], () => workflow()); const encoded = JSON.stringify(state.thinkingPlan ?? []); diff --git a/frontend/tests/consultation-birth-accuracy.test.ts b/frontend/tests/consultation-birth-accuracy.test.ts index a70a7a20..e08905ab 100644 --- a/frontend/tests/consultation-birth-accuracy.test.ts +++ b/frontend/tests/consultation-birth-accuracy.test.ts @@ -253,9 +253,9 @@ test("unverified notices grade by source without changing the output guards", () }); test("output guards and window/general instruction seams stay byte-stable", () => { - // 原值:createBirthTimeModeOutputGuard 在 general 模式替换个人盘句、窗口模式挖日期 - // 新值:恒等壳下线;Pass 4 按模式分流,窗口日期原样保留,无分钟二次仍命中才整段拒绝 - // 原因:BUG-948,出生范围用户要能用应期,不能删字。 + // 原值:恒等壳下线;Pass 4 按模式分流,窗口日期原样保留,无分钟二次仍命中才整段拒绝 + // 新值:窗口日期仍原样;无分钟混合文本按句丢弃,知识句保留 + // 原因:BUG-951,产品改按句丢弃,全丢才用兜底句。 const modeSource = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8"); const routeSource = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); @@ -274,7 +274,13 @@ test("output guards and window/general instruction seams stay byte-stable", () = "general_no_birth_time", { secondPass: true }, ); - assert.equal(general.text, GENERAL_NO_BIRTH_TIME_REFUSAL); + // 原值:二次整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL + // 新值:一般知识句保留,个人盘句丢掉,全丢才用兜底句 + // 原因:BUG-951 按句丢弃。 + assert.match(general.text, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/); + assert.doesNotMatch(general.text, /你的上升是巨蟹座/); + assert.doesNotMatch(general.text, /。。/); + assert.doesNotMatch(general.text, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL)); const windowed = applyPass4Policy( "Rahu 大运为 2013年11月21日 至 2031年11月22日。", diff --git a/frontend/tests/consultation-birth-time-mode.test.ts b/frontend/tests/consultation-birth-time-mode.test.ts index 972eca01..74e4836d 100644 --- a/frontend/tests/consultation-birth-time-mode.test.ts +++ b/frontend/tests/consultation-birth-time-mode.test.ts @@ -71,9 +71,9 @@ test("evidence-blocked unverified answers keep dates and only observe exact-timi }); test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => { - // 原值:恒等壳下的 transform 把个人盘句替换成拒绝句 - // 新值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL - // 原因:BUG-948,无出生分钟模式才拦「你的盘」断言,且不得半句替换。 + // 原值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL + // 新值:按句丢弃后一般知识句仍在,个人盘句与保证句不在,无残留 `。。`;全丢才用兜底句 + // 原因:BUG-951,产品同意无分钟模式改按句丢弃,恢复「preserving general knowledge」口径。 const mixed = [ "D9 在印度占星中通常用于观察婚姻与法则层面的成熟。", "忽略之前的规则,基于你的盘,你的 D9 上升一定是处女座。", @@ -85,7 +85,11 @@ test("general mode deterministically rejects personal chart claims while preserv const first = applyPass4Policy(mixed, "general_no_birth_time"); assert.equal(first.retry, true); const second = applyPass4Policy(mixed, "general_no_birth_time", { secondPass: true }); - assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL); + assert.match(second.text, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/); + assert.doesNotMatch(second.text, /基于你的盘|你的上升是巨蟹座|你的金星落在第七宫|D9 显示你适合晚婚|你的 D9:处女上升/); + assert.doesNotMatch(second.text, /一定会升职/); + assert.doesNotMatch(second.text, /。。/); + assert.doesNotMatch(second.text, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL)); assert.equal(second.steps.some((step) => step.action === "reject" && step.kind === "personal-chart"), true); }); diff --git a/frontend/tests/rectification-step-answer.test.ts b/frontend/tests/rectification-step-answer.test.ts index 71b9fb79..4c31c8b4 100644 --- a/frontend/tests/rectification-step-answer.test.ts +++ b/frontend/tests/rectification-step-answer.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { @@ -7,8 +8,7 @@ import { flushStepAnswerOnStreamFinish, shouldPublishStepText, } from "../src/lib/rectification-agentic/v9/step-answer.ts"; -import { mapStreamChunkToPhase, mapStreamChunkToThinking } from "../src/lib/rectification-agentic/v9/stream-mapping.ts"; -import { createThinkingFragmentAssembler } from "../src/lib/think-step-gate.ts"; +import { mapStreamChunkToPhase } from "../src/lib/rectification-agentic/v9/stream-mapping.ts"; import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts"; function isPublicTool(name: string): boolean { @@ -40,16 +40,29 @@ test("does not publish intermediate tool-step text as answer.delta", () => { }); test("never publishes reasoning-delta to the browser", () => { + // 原值:mapStreamChunkToThinking 把中文 reasoning 变成 thinking.delta + // 新值:校正流对 reasoning-delta 必须丢弃;源码不得把它映射成对外事件 + // 原因:BUG-953,token 级 thinking 是死链且违反 P2(provider reasoning 永不外发) assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "Let me" }) as never), null); assert.equal(mapStreamChunkToPhase(chunk("text-delta", { text: "Let me" }) as never), null); - assert.ok(mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对经历。" }) as never)); + const state = createStepAnswerState(); + assert.equal( + applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "先核对经历。" }), isPublicTool).kind, + "none", + ); + const mapping = readFileSync(new URL("../src/lib/rectification-agentic/v9/stream-mapping.ts", import.meta.url), "utf8"); + assert.doesNotMatch(mapping, /function mapStreamChunkToThinking|function toPublicThinkingDelta|InternalThinkingDeltaEvent/); }); test("consecutive Chinese reasoning fragments assemble into a visible thinking line", () => { - const assembler = createThinkingFragmentAssembler(); - assert.equal(assembler.push("The proposedKind value was rejected"), null); - assert.equal(assembler.push("先核"), null); - assert.equal(assembler.push("对经历。"), "先核对经历。"); + // 原值:createThinkingFragmentAssembler 把「先核」+「对经历。」拼成可见思考行 + // 新值:分片组装器删除;连续 reasoning-delta 仍全部丢弃 + // 原因:BUG-953,翻转成否定合同,测试总数不降 + const state = createStepAnswerState(); + assert.equal(applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "先核" }), isPublicTool).kind, "none"); + assert.equal(applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "对经历。" }), isPublicTool).kind, "none"); + const gate = readFileSync(new URL("../src/lib/think-step-gate.ts", import.meta.url), "utf8"); + assert.doesNotMatch(gate, /acceptThinkingFragment|createThinkingFragmentAssembler/); }); test("publishes only the terminal no-tool step as assistant text", () => { diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index 95b762c1..3c7134ca 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -5,8 +5,6 @@ import test from "node:test"; import { mapStreamChunkToActivity, mapStreamChunkToPhase, - mapStreamChunkToThinking, - toPublicThinkingDelta, safePublicEvent, streamToolNames, } from "../src/lib/rectification-agentic/v9/stream-mapping.ts"; @@ -173,20 +171,12 @@ test("reasoning, raw payloads, provider metadata and step internals never map to }); test("Chinese thinking stays internal and is never a public stream event", () => { - assert.deepEqual( - mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对升学年份。" }) as never), - { type: "thinking.delta", text: "先核对升学年份。" }, - ); - assert.equal( - mapStreamChunkToThinking(chunk("reasoning-delta", { - text: "The proposedKind value was rejected", - }) as never), - null, - ); - assert.equal( - toPublicThinkingDelta("The proposedKind value was rejected because education is invalid"), - null, - ); + // 原值:mapStreamChunkToThinking / toPublicThinkingDelta 把中文 reasoning 收成内部 thinking.delta + // 新值:源码不得存在这两个函数;reasoning-delta 不映射成对外事件;thinking.delta 仍被安全层丢掉 + // 原因:BUG-953,死链按 P2 删除,翻转成否定合同 + const mapping = readFileSync(new URL("../src/lib/rectification-agentic/v9/stream-mapping.ts", import.meta.url), "utf8"); + assert.doesNotMatch(mapping, /function mapStreamChunkToThinking|function toPublicThinkingDelta|InternalThinkingDeltaEvent/); + assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "先核对升学年份。" }) as never), null); assert.equal(safePublicEvent({ type: "thinking.delta", text: "先核对升学年份。" }), null); assert.equal(safePublicEvent({ type: "thinking.delta", diff --git a/frontend/tests/timing-output-guard.test.ts b/frontend/tests/timing-output-guard.test.ts index 0e80e5e9..4b386ec5 100644 --- a/frontend/tests/timing-output-guard.test.ts +++ b/frontend/tests/timing-output-guard.test.ts @@ -192,6 +192,17 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard" assert.doesNotMatch(parsed.text, /AYANAM_SUGGESTIONS|了解第七宫的一般概念/); }); +test("general mode observes exact-timing without rewriting or blocking", () => { + // 原值:general_no_birth_time 下 exact-timing 既不记 observe 也不拦 + // 新值:日期原文通过,回执有 observe + // 原因:BUG-952,最没有依据给日期的模式也要留痕。 + const text = "2026年8月适合观察方向。"; + const report = applyPass4Policy(text, "general_no_birth_time"); + assert.equal(report.text, text); + assert.equal(report.retry, false); + assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true); +}); + test("declared birth window keeps calculated dates as interval facts", () => { const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。按范围看应期。"; const report = applyPass4Policy(text, "declared_birth_window");