fix(consult): 降级正文过 Pass 4,去重并停止空跑 compose(BUG-959/960/961)

降级交付复用 releasePass4Sentences;每次 attempt 清空 uncontractedText;成功降级后跳过 compose。
This commit is contained in:
jesse-ux
2026-09-18 18:45:18 +08:00
parent bed961c4a1
commit 5e8b2ac112
6 changed files with 304 additions and 28 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-18 — 没跑完计算时,保证性结论仍不会发出
合同没跑完但模型写了字时,那段回答会先按句子过一遍合规门:保证性结论整句拿掉,其余留下,并注明这次没跑完星盘计算。不会只丢一句说明、上面空空的。同一轮重试过的旧稿不会再拼进这次回答。Skill 版本不变。
## 2026-09-18 — 申报时段咨询重新能回答
只知道一段出生范围时,问事业 / 运势会在几十毫秒内失败,提示「未完成必要的方法与计算步骤」,从八月下旬起一直如此。根因是这条路线的助手装了方法校验、却没把方法写进系统提示,模型一次都没被叫到。现在方法已经装上;没有跑完计算但模型写过字时,会把那段留下并注明「没跑完星盘计算」,不再整轮空白。Skill 版本不变。
+48
View File
@@ -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
@@ -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 error120 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 权限,不写成通过。
+1 -1
View File
@@ -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) | 已验收通过(Claudetsc 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,输入处理器直接 abortBUG-954)。abort 与「模型没调工具」同码,是它藏四周的原因(955);另含合同未绿不得丢正文(956)、计算不该由模型触发(957,须等 954 上线后另轮)、窗口指令应期冲突(958)。基线 `9cdcf96b` | 已验收通过(Claudetsc 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-968P0,代码级确认)。另含 BUG-969:校正「换一件事问」后无下文,服务端已出下一题且快照重算完整,客户端停在开场状态,GET 路由与客户端两端静默,本单只做可观测 + 不静默 + 题干进正文。基线 `1061514f` | 待领取 | — |
+61 -27
View File
@@ -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<Uint8Array> | 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<Uint8Array>({
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;
@@ -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 = "";