diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c1ecea..495e6e53 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 adc06bf9..6daf3a7b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -12600,6 +12600,54 @@ - 复发自:无 - 修复版本:— +## BUG-959 | 降级正文绕过 Pass 4,保证性结论原样送达 + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`stream-agent-response.ts` 合同未绿时的降级交付 +- 用户现象:没跑完星盘计算时,模型自己写的「我保证你一定会升职」一类句子会进正文。 +- 触发条件:`requireTool: true`、零次成功计算、有非空正文、`pass4Mode` 为有盘模式。 +- 根因:BUG-956 的降级分支直接 `send(uncontractedText)`,没有走 `releasePass4Sentences` / `classifyPass4`。降级正文恰恰是没有任何计算依据时写的,保证句和个人盘断言最容易出现在这里。 +- 修复:降级正文复用 `releasePass4Sentences` 按句过门,`reject` 整句丢弃,`observe` 照记。`CONTRACT_DEGRADED_NOTE` 是服务端文案,不过 Pass 4,贴在末尾。全部句子被丢弃时:`general_no_birth_time` 发拒绝句再附说明;其余模式维持 `runtime_contract_incomplete`,不发只有降级说明的空回答。不在句内改写。 +- 验证:假流降级 + `verified_chart` + 保证句与安全句 → 保证句不出现在任何 `answer.delta`,回执同时有 `contract-degraded` 与 `pass4-reject:guarantee`;全丢不发 note-only;一般模式全丢发拒绝句 + 说明。 +- 防复发:降级路径必须复用 `releasePass4Sentences`,不得另写分句或句内替换。Pass 4 是门不是刀。 +- 关联记录:BUG-956、BUG-948、BUG-950、BUG-951 +- 复发自:BUG-956(降级路径漏接 Pass 4) +- 修复版本:`ae2e46a6` + +## BUG-960 | 两次 attempt 的正文被拼起来一起送出 + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`stream-agent-response.ts` `uncontractedText` +- 用户现象:合同重试后降级交付把第一稿和重试稿拼成一段,同一轮内容说两遍。 +- 触发条件:第一次 attempt 写过字,合同仍红,retry 再写一段不同的字。 +- 根因:`uncontractedText += text` 只在 `contractReady()` 为真时清零。合同一直红就一路累加,attempt 之间没有边界。 +- 修复:每次 `consumeAttempt` 开始时清空 `uncontractedText`。降级只交付最后一次 attempt 的正文。 +- 验证:假流 attempt1「第一段。」、attempt2「第二段。」、无工具成功 → 降级正文含第二段、不含第一段。 +- 防复发:未绿缓冲按 attempt 隔离,不得跨 attempt 累加。 +- 关联记录:BUG-956、BUG-959 +- 复发自:BUG-956 +- 修复版本:`ae2e46a6` + +## BUG-961 | 降级之后还会空跑一轮 compose + +- 状态:resolved +- 首次发现:2026-09-18 +- 最近更新:2026-09-18 +- 影响面:`stream-agent-response.ts` 降级后的收口 +- 用户现象:无(用户看不到 compose 产出)。本命形状的事件序列里出现 `phase.started{phase:"compose"}`,白花一次模型调用与一段墙钟。 +- 触发条件:降级交付成功,且该路径传入了 `composeAnswer`(本命线)。 +- 根因:降级分支执行后流程继续走到 `publishFindings` / `composeOnce`。 +- 修复:成功降级后设 `deliveredDegraded`,跳过 Pass 2 / Pass 3 / compose / answer-retry,直接 `run.completed`。全丢的非一般模式在 compose 之前抛 `runtime_contract_incomplete`。 +- 验证:降级路径 `composeAnswer` 调用次数为 0,事件无 `phase.started{phase:"compose"}`。 +- 防复发:降级是终态交付,不得再进 compose。 +- 关联记录:BUG-956、BUG-959 +- 复发自:BUG-956 +- 修复版本:`ae2e46a6` + ## BUG-958 | 窗口指令「必须调工具」与「不得给应期」未写清,模型可能跳过计算或拒答 - 状态:resolved diff --git a/docs/tasks/PROGRESS-contract-degraded-pass4-20260918.md b/docs/tasks/PROGRESS-contract-degraded-pass4-20260918.md new file mode 100644 index 00000000..ca0f149e --- /dev/null +++ b/docs/tasks/PROGRESS-contract-degraded-pass4-20260918.md @@ -0,0 +1,37 @@ +# PROGRESS · 降级交付路径补 Pass 4 与去重(2026-09-18) + +工作树:`.worktrees/contract-degraded-pass4-20260918` +分支:`codex/contract-degraded-pass4-20260918` +基线:`origin/staging` @ `1061514f`(任务书写 `877128ce`,其后远端已快进) +任务书:`docs/tasks/TASK-contract-degraded-pass4-20260918.md` + +## 结论 + +降级正文现在按句过 Pass 4:保证句整句丢掉,其余留下,服务端说明贴在末尾。两次 attempt 不再拼在一起。降级之后不再空跑 compose。合同门 `contractReady()` 没放宽。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| BUG-959 降级正文过 Pass 4 | 完成 | 复用 `releasePass4Sentences`;全丢时一般模式发拒绝句,其余模式仍 `runtime_contract_incomplete` | +| BUG-960 每次 attempt 清空缓冲 | 完成 | 降级只交付最后一次 attempt | +| BUG-961 降级后跳过 compose | 完成 | `composeAnswer` 零次调用,事件无 `phase.started{compose}` | +| BUG-957 窗口预跑 | 本文件不覆盖 | 见 `PROGRESS-window-consult-precompute-20260918.md` | + +## 断言三栏(既有测试) + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| BUG-956 有正文降级 | 未传 `pass4Mode` | `pass4Mode: "verified_chart"` | 任务书:e32ce624 之后的既有降级测试补上该参数;正文「不能保存」不含 reject 句,行为不变 | +| BUG-956 无正文 | 未传 `pass4Mode` | 同上 | 同上,空正文仍走 `runtime_contract_incomplete` | + +## 测试 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(120 warning,与基线同量级,未顺手修) | +| `consultation-agentic-runtime.test.ts` + `timing-output-guard.test.ts` | 88 pass / 1 fail。失败是本机 Windows `EPERM` 无法为 live skill 建 symlink(`personal Agent exposes the Jyotish Skill…`),与基线缺口同类,不是本单引入 | +| 本单新增 | 降级+保证句不出现在任何 `answer.delta`,回执同时有 `contract-degraded` 与 `pass4-reject:guarantee`;全丢不发只有降级说明的空回答;两次 attempt 只留第二段;降级路径 `composeAnswer` 调用次数为 0 | + +Linux 门禁才是验收数字:目标 fail 回到 31,总数不低于基线 3501。本机无登录态、无 Chrome、Windows 无 symlink 权限,不写成通过。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 09281262..3b027804 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -139,7 +139,7 @@ | `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` | `PROGRESS-consult-pass4-streaming-20260918.md` | 验收 review 三轮:Pass 4 一 hold 正文就整段蹦出、逐字流式消失(BUG-950 产品拍板按句放行);无出生分钟模式整段被一句拒绝顶掉、一般知识句一起丢(951 改按句丢弃);该模式下日期不留痕(952);校正流 token 级 thinking 是死链,按 P2 删除并把测试翻转成否定合同(953)。基线 `1e553976` | 待验收 | `cd4775ae` | | `TASK-window-consult-contract-20260918.md` | `PROGRESS-window-consult-contract-20260918.md` | **P0 线上**:申报时段模式**自 2026-08-21 起每轮秒败**(69ms、0 token、模型从未被调用)。服务端日志实证根因:窗口 Agent attach 了 `jyotishSkillBinding()`,但 `windowJyotishInstructions` 从来不含方法块 marker,输入处理器直接 abort(BUG-954)。abort 与「模型没调工具」同码,是它藏四周的原因(955);另含合同未绿不得丢正文(956)、计算不该由模型触发(957,须等 954 上线后另轮)、窗口指令应期冲突(958)。基线 `9cdcf96b` | 已验收通过(Claude:tsc 0 / lint 0 error / npm test 3501 条 31 红同基线 / `/` Static / gzip 无变化;实跑确认窗口 Agent 指令已含 marker);review 另出 BUG-959~961 见下一行 | `5b6abc23` | -| `TASK-contract-degraded-pass4-20260918.md` | — | 验收 review:BUG-956 新增的降级交付路径绕过 Pass 4,保证句原样送达(BUG-959);`uncontractedText` 跨 attempt 不清零,同一轮正文说两遍(960);降级后还空跑一轮 compose(961)。基线 `877128ce` | 待领取 | — | +| `TASK-contract-degraded-pass4-20260918.md` | `PROGRESS-contract-degraded-pass4-20260918.md` | 验收 review:BUG-956 新增的降级交付路径绕过 Pass 4,保证句原样送达(BUG-959);`uncontractedText` 跨 attempt 不清零,同一轮正文说两遍(960);降级后还空跑一轮 compose(961)。基线 `877128ce` | 待验收 | `ae2e46a6` | | `TASK-chat-markdown-list-20260918.md` | — | 真机排版反馈:聊天正文列表**没有项目符号**(Tailwind v4 preflight 清了 `list-style`,`.markdown-list` 没恢复,BUG-962);`promoteDefinitionLists` 把四标题口语体的散文误判成并列项,三段正文被改写成列表(BUG-963,判据太松,收紧而非删除——BUG-356 的场景要留);思考条与正文之间实测 56px=报告 grid gap 24 + 首标题 margin-top 32,后者是被特指度压掉的「首元素清零」规则(BUG-964)。基线 `ea0280c1` | 待领取 | — | | `TASK-secondary-page-entry-20260918.md` | — | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT` 与 `/api/health` 比对自愈。基线 `41902067` | 待领取 | — | | `TASK-account-dialog-inert-20260918.md` | — | 真机:账户弹窗打开后整个弹窗点不动、退出登录做不了,刷新依旧——`e4e73f56` 把 `SidebarInset` 搬进 layout 后 `inert={modalOpen}` 罩住了没有 portal 的 `AccountDialogOverlay`(BUG-968,P0,代码级确认)。另含 BUG-969:校正「换一件事问」后无下文,服务端已出下一题且快照重算完整,客户端停在开场状态,GET 路由与客户端两端静默,本单只做可观测 + 不静默 + 题干进正文。基线 `1061514f` | 待领取 | — | diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index db31878c..00a48096 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -466,6 +466,9 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { stream: ChunkStream, attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean } = {}, ) { + // Each attempt owns its own uncontracted buffer. Accumulating across the + // contract retry delivered the first draft and the retry as one answer. + uncontractedText = ""; const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value)); let held = ""; let composingSent = Boolean(attempt.suppressCompositionActivity); @@ -687,6 +690,46 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { return true; } + async function deliverDegradedAnswer( + controller: ReadableStreamDefaultController | undefined, + ) { + const origin = fullOutput; + if (options.pass4Mode) { + await releasePass4Sentences(controller, uncontractedText, false); + await releasePass4Sentences(controller, "", true); + } else if (/\S/.test(uncontractedText)) { + if (!firstOutput) { + firstOutput = true; + await options.onFirstOutput?.(); + } + send(controller, { type: "answer.delta", text: uncontractedText }); + fullOutput += uncontractedText; + emitted = true; + } + if (!/\S/.test(fullOutput.slice(origin.length))) { + if (options.pass4Mode === "general_no_birth_time") { + if (!firstOutput) { + firstOutput = true; + await options.onFirstOutput?.(); + } + send(controller, { type: "answer.delta", text: GENERAL_NO_BIRTH_TIME_REFUSAL }); + fullOutput += GENERAL_NO_BIRTH_TIME_REFUSAL; + emitted = true; + } else { + return false; + } + } + appendConsultationRuntimeStep(options.state, { + kind: "validation", + name: CONTRACT_DEGRADED_STEP, + status: "failed", + }); + send(controller, { type: "answer.delta", text: CONTRACT_DEGRADED_NOTE }); + fullOutput += CONTRACT_DEGRADED_NOTE; + emitted = true; + return true; + } + const body = new ReadableStream({ start(controller) { const sideEvent = options.sideEvent @@ -700,6 +743,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { for (const event of skillBoundEvents) send(controller, event); flushThinkingPlan(controller); try { + let deliveredDegraded = false; await consumeAttempt(controller, options.stream, { drainSpoken: Boolean(options.composeAnswer), }); @@ -715,21 +759,9 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { && options.state.consultationToolSuccessCount === 0 && /\S/.test(uncontractedText); if (canDegrade) { - appendConsultationRuntimeStep(options.state, { - kind: "validation", - name: CONTRACT_DEGRADED_STEP, - status: "failed", - }); - if (!firstOutput) { - firstOutput = true; - await options.onFirstOutput?.(); - } - send(controller, { type: "answer.delta", text: uncontractedText }); - fullOutput += uncontractedText; - send(controller, { type: "answer.delta", text: CONTRACT_DEGRADED_NOTE }); - fullOutput += CONTRACT_DEGRADED_NOTE; - emitted = true; - } else { + deliveredDegraded = await deliverDegradedAnswer(controller); + } + if (!deliveredDegraded) { appendConsultationRuntimeStep(options.state, { kind: "validation", name: RUNTIME_CONTRACT_INCOMPLETE_STEP, @@ -738,18 +770,20 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { 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: "正在组织回答" }); - const retryOrigin = fullOutput; - await consumeAttempt(controller, await options.retryForAnswer()); - if (options.pass4Mode) await finishPass4(controller, retryOrigin, findings, false); + if (!deliveredDegraded) { + 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: "正在组织回答" }); + 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"); settling = true; diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index 43c1a768..8fc67a4e 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -1279,6 +1279,7 @@ test("incomplete runtime contract with body is delivered degraded instead of dis } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + pass4Mode: "verified_chart", toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onComplete: () => { completed += 1; }, onError: () => { failed += 1; }, @@ -1310,6 +1311,7 @@ test("incomplete runtime contract without body still fails closed (BUG-956)", as async function* chunks() {} const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + pass4Mode: "verified_chart", toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onComplete: () => { completed += 1; }, onError: () => { failed += 1; }, @@ -1329,6 +1331,157 @@ test("incomplete runtime contract without body still fails closed (BUG-956)", as step.kind === "validation" && step.name === "runtime-contract-incomplete" && step.status === "failed")); }); +test("degraded delivery drops guarantee sentences through Pass 4 (BUG-959)", async () => { + const state = createConsultationRuntimeState(); + let completed = 0; + async function* chunks() { + yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } }; + yield { type: "text-delta", payload: { text: "方向上可以推进。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + pass4Mode: "verified_chart", + toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + onComplete: () => { completed += 1; }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + assert.equal(completed, 1); + 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.some((text) => /一定会升职|我保证/.test(text)), false); + assert.match(answers.join(""), /方向上可以推进/); + assert.equal(answers.join("").includes(CONTRACT_DEGRADED_NOTE), true); + const completedEvent = events.find((event) => (event as { type?: string }).type === "run.completed") as { + receipt?: { steps: Array<{ kind: string; name: string; status: string }> }; + }; + assert.ok(completedEvent.receipt?.steps.some((step) => + step.kind === "validation" && step.name === "contract-degraded" && step.status === "failed")); + assert.ok(completedEvent.receipt?.steps.some((step) => + step.kind === "validation" && step.name === "pass4-reject:guarantee" && step.status === "failed")); +}); + +test("degraded delivery that Pass 4 empties stays incomplete instead of a note-only answer (BUG-959)", async () => { + const state = createConsultationRuntimeState(); + let completed = 0; + let failed = 0; + async function* chunks() { + yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + pass4Mode: "verified_chart", + toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + onComplete: () => { completed += 1; }, + onError: () => { failed += 1; }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + assert.equal(completed, 0); + assert.equal(failed, 1); + const answer = events + .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") + .map((event) => event.text) + .join(""); + assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), false); + const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { + code: string; + receipt?: { steps: Array<{ name: string }> }; + }; + assert.equal(failure.code, "runtime_contract_incomplete"); + assert.ok(failure.receipt?.steps.some((step) => step.name === "pass4-reject:guarantee")); + assert.ok(failure.receipt?.steps.some((step) => step.name === "runtime-contract-incomplete")); +}); + +test("degraded delivery uses the refusal when Pass 4 drops every general-mode sentence (BUG-959)", async () => { + const state = createConsultationRuntimeState(); + async function* chunks() { + yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + pass4Mode: "general_no_birth_time", + toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + const answer = events + .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") + .map((event) => event.text) + .join(""); + assert.match(answer, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL)); + assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true); + assert.doesNotMatch(answer, /你的上升是巨蟹座/); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); +}); + +test("degraded delivery keeps only the last attempt body (BUG-960)", async () => { + const state = createConsultationRuntimeState(); + async function* first() { + yield { type: "text-delta", payload: { text: "第一段。" } }; + } + async function* second() { + yield { type: "text-delta", payload: { text: "第二段。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: first(), requireTool: true, + pass4Mode: "verified_chart", + retry: async () => second(), + toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + const answer = 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, /第一段/); + assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); +}); + +test("degraded delivery does not start a compose pass (BUG-961)", async () => { + const state = createConsultationRuntimeState(); + let composeCalls = 0; + async function* chunks() { + yield { type: "text-delta", payload: { text: "方向上可以推进。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + pass4Mode: "verified_chart", + toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + composeAnswer: async () => { + composeCalls += 1; + async function* composed() { + yield { type: "text-delta", payload: { text: "不该出现的 compose。" } }; + yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; + } + return composed(); + }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + assert.equal(composeCalls, 0); + assert.equal(events.some((event) => { + const item = event as { type?: string; phase?: string }; + return item.type === "phase.started" && item.phase === "compose"; + }), false); + const answer = 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, /不该出现的 compose/); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); +}); + test("skill-binding abort is a distinct receipt step from a missing tool call (BUG-955)", async () => { const bindingState = createConsultationRuntimeState(); let bindingError = "";