# 微信消息分块优化 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把 Claude CLI 最终答案从"按段落碎片推送"改为"按回合整段推送",仅在超 4000 字时按段落硬切;agent loop 期间的 interstitial 保持实时段落推送。 **Architecture:** 在 `provider.ts` 暴露 `onTurnEnd(stopReason)` 回调(基于 `message_delta` 事件的 `stop_reason` 字段);新增 `src/claude/turn-router.ts` 的 `TurnRouter` 类把 `text_delta` 按回合累积,根据 `stop_reason` 分流为 interstitial(立即发)或 final(流结束发);`main.ts` 替换原 `textBuffer` 逻辑,接入 TurnRouter,删除段落边界 flush 相关死代码。 **Tech Stack:** TypeScript(strict)、Node.js ESM(`"type": "module"`、Node16 module resolution)、Node 内置 test runner(`node --test`)。 ## Global Constraints - 不改 `src/wechat/api.ts` 的限流逻辑(2.5s 间隔、60s 冷却、指数退避保持原样)。 - 不改 `MAX_MESSAGE_LENGTH = 4000`、`splitMessage`、`parseBlocks`、`findSafeSplitPoint`、`splitByNewline`。 - 不改 typing 指示器、silence warning 5min 兜底(`flushTimer`)、文件自动推送。 - TypeScript strict 模式,编译命令 `npm run build`(`tsc`)。 - 测试入口 `npm test` = `node --test dist/tests/*.test.js`,必须先 `npm run build`。 - ESM 导入必须带 `.js` 后缀(即便源是 `.ts`)。 - 提交信息遵循现有风格:`type: 中文描述`(参考 `git log`)。 **Spec 参考**:`docs/superpowers/specs/2026-06-20-message-batching-design.md` --- ## Task 1: 提取 `handleStreamLine` 到 provider.ts(纯重构,为可测性铺路) **Files:** - Modify: `src/claude/provider.ts`(提取 `rl.on('line', ...)` 内的 switch 到导出函数) - Create: `src/tests/provider.test.ts` **Interfaces:** - Consumes: 无(首个任务) - Produces: - `export interface StreamParserState { sessionId: string; textParts: string[]; errorMessage?: string; trackingSkill: boolean; skillInputAccum: string; }` - `export interface StreamParserCallbacks { onText?: (text: string) => void; onBlockEnd?: () => void; }` - `export function handleStreamLine(line: string, state: StreamParserState, callbacks: StreamParserCallbacks): void` - [ ] **Step 1: 写覆盖现有行为的失败测试** Create `src/tests/provider.test.ts`: ```ts import { test } from 'node:test'; import assert from 'node:assert/strict'; import { handleStreamLine, type StreamParserState } from '../claude/provider.js'; function freshState(): StreamParserState { return { sessionId: '', textParts: [], trackingSkill: false, skillInputAccum: '' }; } test('handleStreamLine: system init 设置 sessionId', () => { const state = freshState(); handleStreamLine( JSON.stringify({ type: 'system', subtype: 'init', session_id: 'sess-123' }), state, {}, ); assert.equal(state.sessionId, 'sess-123'); }); test('handleStreamLine: text_delta 触发 onText', () => { const calls: string[] = []; handleStreamLine( JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hello' } }, }), freshState(), { onText: (t) => calls.push(t) }, ); assert.deepEqual(calls, ['hello']); }); test('handleStreamLine: content_block_stop 触发 onBlockEnd', () => { let called = 0; handleStreamLine( JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }), freshState(), { onBlockEnd: () => called++ }, ); assert.equal(called, 1); }); test('handleStreamLine: assistant 消息文本累积到 textParts', () => { const state = freshState(); handleStreamLine( JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: '回复内容' }] }, }), state, {}, ); assert.deepEqual(state.textParts, ['回复内容']); }); test('handleStreamLine: 空行和非法 JSON 静默跳过', () => { const state = freshState(); handleStreamLine('', state, {}); handleStreamLine('not json', state, {}); handleStreamLine(' ', state, {}); assert.deepEqual(state.textParts, []); }); ``` - [ ] **Step 2: 跑测试确认失败(函数不存在)** Run: ```bash npm run build && node --test dist/tests/provider.test.js ``` Expected: 编译错误 `handleStreamLine is not exported` 或测试运行报错 `cannot find module`。 - [ ] **Step 3: 实现提取** In `src/claude/provider.ts`: **3a. 在文件顶部 imports 之后、`claudeQuery` 之前,加入类型定义和提取的函数:** ```ts // --------------------------------------------------------------------------- // Stream parser (extracted for testability) // --------------------------------------------------------------------------- export interface StreamParserState { sessionId: string; textParts: string[]; errorMessage?: string; trackingSkill: boolean; skillInputAccum: string; } export interface StreamParserCallbacks { onText?: (text: string) => void; onBlockEnd?: () => void; } export function handleStreamLine( line: string, state: StreamParserState, callbacks: StreamParserCallbacks, ): void { if (!line.trim()) return; let obj: any; try { obj = JSON.parse(line); } catch { return; } switch (obj.type) { case 'system': { if (obj.subtype === 'init' && obj.session_id) { state.sessionId = obj.session_id; } break; } case 'assistant': { const content = obj.message?.content; if (Array.isArray(content)) { const text = content .filter((b: any) => b.type === 'text') .map((b: any) => b.text ?? '') .join(''); if (text) state.textParts.push(text); } break; } case 'stream_event': { const evt = obj.event; if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') { if (evt.content_block.name === 'Skill') { state.trackingSkill = true; state.skillInputAccum = ''; } } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') { const delta: string = evt.delta.text; if (delta && callbacks.onText) { callbacks.onText(delta); } } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && state.trackingSkill) { state.skillInputAccum += evt.delta.partial_json ?? ''; try { const parsed = JSON.parse(state.skillInputAccum); if (parsed.skill) { const msg = `\n正在调用 ${parsed.skill} 技能\n\n`; if (callbacks.onText) callbacks.onText(msg); state.trackingSkill = false; } } catch { // JSON not complete yet } } else if (evt?.type === 'content_block_stop') { state.trackingSkill = false; if (callbacks.onBlockEnd) callbacks.onBlockEnd(); } break; } case 'result': { if (obj.result && typeof obj.result === 'string') { const combined = state.textParts.join(''); if (!combined.includes(obj.result)) { state.textParts.push(obj.result); } } if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) { const errors = obj.errors ?? [obj.error_message ?? 'Unknown error']; state.errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors); logger.error('CLI returned error result', { errors }); } break; } default: break; } } ``` **3b. 在 `claudeQuery` 内替换原 `rl.on('line', ...)` 块。** 找到现有的: ```ts // Parse NDJSON from stdout let skillInputAccum = ''; let trackingSkill = false; const rl = createInterface({ input: child.stdout! }); rl.on('line', (line: string) => { if (!line.trim()) return; let obj: any; try { obj = JSON.parse(line); } catch { // Skip unparseable lines return; } switch (obj.type) { case 'system': { if (obj.subtype === 'init' && obj.session_id) { sessionId = obj.session_id; } break; } case 'assistant': { const content = obj.message?.content; if (Array.isArray(content)) { const text = content .filter((b: any) => b.type === 'text') .map((b: any) => b.text ?? '') .join(''); if (text) textParts.push(text); } break; } case 'stream_event': { const evt = obj.event; if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') { if (evt.content_block.name === 'Skill') { trackingSkill = true; skillInputAccum = ''; } } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') { const delta: string = evt.delta.text; if (delta && onText) { Promise.resolve(onText(delta)).catch(() => {}); } } else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && trackingSkill) { skillInputAccum += evt.delta.partial_json ?? ''; try { const parsed = JSON.parse(skillInputAccum); if (parsed.skill) { const msg = `\n正在调用 ${parsed.skill} 技能\n\n`; if (onText) Promise.resolve(onText(msg)).catch(() => {}); trackingSkill = false; } } catch { // JSON not complete yet, keep accumulating } } else if (evt?.type === 'content_block_stop') { trackingSkill = false; if (onBlockEnd) Promise.resolve(onBlockEnd()).catch(() => {}); } break; } case 'result': { if (obj.result && typeof obj.result === 'string') { const combined = textParts.join(''); if (!combined.includes(obj.result)) { textParts.push(obj.result); } } if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) { const errors = obj.errors ?? [obj.error_message ?? 'Unknown error']; errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors); logger.error('CLI returned error result', { errors }); } break; } default: break; } }); ``` 替换为: ```ts // Parse NDJSON from stdout (logic in handleStreamLine for testability) const parserState: StreamParserState = { sessionId: '', textParts: [], trackingSkill: false, skillInputAccum: '', }; const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd }; const rl = createInterface({ input: child.stdout! }); rl.on('line', (line: string) => { handleStreamLine(line, parserState, parserCallbacks); }); ``` **3c. 把所有读写 `sessionId` / `textParts` / `errorMessage` 的地方改成操作 `parserState.*`。** 在 `claudeQuery` 内: - 函数开头删除三个局部声明:`let sessionId = '';`、`const textParts: string[] = [];`、`let errorMessage: string | undefined;`(状态现在在 `parserState` 里)。 - timeout handler 内:`const partialText = textParts.join('\n').trim();` → `parserState.textParts.join('\n').trim();`;`finish({ ..., sessionId, ... })` → `finish({ ..., sessionId: parserState.sessionId, ... })`。 - onAbort 内:同上两处替换。 - `child.on('close', ...)` 内(5 处替换,含读和写): - `!textParts.length && !errorMessage` → `!parserState.textParts.length && !parserState.errorMessage` - `errorMessage = stderr || \`claude exited with code ${code}\`;` → `parserState.errorMessage = stderr || \`claude exited with code ${code}\`;` - `const fullText = textParts.join('\n').trim();` → `parserState.textParts.join('\n').trim();` - `if (!fullText && !errorMessage)` → `if (!fullText && !parserState.errorMessage)` - `errorMessage = 'Claude returned an empty response.';` → `parserState.errorMessage = 'Claude returned an empty response.';` - `finish({ text: fullText, sessionId, error: errorMessage })` → `finish({ text: fullText, sessionId: parserState.sessionId, error: parserState.errorMessage })` - 日志里 `textLength: fullText.length` 等读取 `fullText` 的不动(它是局部变量)。 - `child.on('error', ...)` 内:`finish({ text: '', sessionId, error: ... })` → `finish({ text: '', sessionId: parserState.sessionId, error: ... })`。 - [ ] **Step 4: 编译并跑测试确认通过** Run: ```bash npm run build && node --test dist/tests/provider.test.js ``` Expected: 5 个测试全 PASS,无 TypeScript 编译错误。 - [ ] **Step 5: 端到端冒烟(确认重构没破坏 claudeQuery)** Run: ```bash echo "你好" | node dist/main.js 2>&1 | head -5 || true ``` Expected: 进程能启动(即使因为没有配置账号/凭证而退出,也不应在 `provider.ts` 上报 TypeError)。如果输出 `未找到账号` 之类的运行期错误,说明导入和类型正常。 - [ ] **Step 6: 提交** ```bash git add src/claude/provider.ts src/tests/provider.test.ts git commit -m "$(cat <<'EOF' refactor: 提取 handleStreamLine 为可测的纯函数 把 claudeQuery 里 rl.on('line') 的 NDJSON 解析 switch 体抽成独立导出函数 handleStreamLine(line, state, callbacks),状态外置到 StreamParserState。 行为完全不变,仅是为后续 onTurnEnd 接入和单元测试铺路。 附首批单元测试覆盖 system init / text_delta / content_block_stop / assistant 文本累积 / 非法行跳过 5 条路径。 Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 2: 在 provider.ts 加 `onTurnEnd` 回调 **Files:** - Modify: `src/claude/provider.ts`(`QueryOptions` 加字段、`handleStreamLine` 加分支、`StreamParserCallbacks` 加字段) - Modify: `src/tests/provider.test.ts`(新增测试) **Interfaces:** - Consumes: Task 1 的 `handleStreamLine` / `StreamParserState` / `StreamParserCallbacks` - Produces: - `QueryOptions.onTurnEnd?: (stopReason: string) => void` - `StreamParserCallbacks.onTurnEnd?: (stopReason: string) => void` - `handleStreamLine` 在收到 `message_delta` 事件且 `delta.stop_reason` 存在时触发 `onTurnEnd` - [ ] **Step 1: 写失败测试** Append to `src/tests/provider.test.ts`: ```ts test('handleStreamLine: message_delta 带 stop_reason 触发 onTurnEnd', () => { const calls: string[] = []; handleStreamLine( JSON.stringify({ type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, }), freshState(), { onTurnEnd: (r) => calls.push(r) }, ); assert.deepEqual(calls, ['end_turn']); }); test('handleStreamLine: message_delta 无 stop_reason 不触发 onTurnEnd', () => { const calls: string[] = []; handleStreamLine( JSON.stringify({ type: 'stream_event', event: { type: 'message_delta', delta: {} }, }), freshState(), { onTurnEnd: (r) => calls.push(r) }, ); assert.deepEqual(calls, []); }); test('handleStreamLine: tool_use stop_reason 也正常透传', () => { const calls: string[] = []; handleStreamLine( JSON.stringify({ type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'tool_use' } }, }), freshState(), { onTurnEnd: (r) => calls.push(r) }, ); assert.deepEqual(calls, ['tool_use']); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: ```bash npm run build && node --test dist/tests/provider.test.js ``` Expected: 3 个新测试 FAIL(`onTurnEnd` 类型不存在或回调不触发),原 5 个 PASS。 - [ ] **Step 3: 实现** **3a. 在 `QueryOptions` 接口加字段**(`src/claude/provider.ts`): 找到现有的: ```ts /** Called when a content block ends — use to flush buffered text. */ onBlockEnd?: () => Promise | void; ``` 在其**之后**加: ```ts /** Called when an assistant turn ends, with its stop_reason * ('tool_use' | 'end_turn' | 'max_tokens' | 'stop_sequence' | 'pause_turn' | ...). * Use to decide whether the turn's text is interstitial or final answer. */ onTurnEnd?: (stopReason: string) => Promise | void; ``` **3b. 在 `StreamParserCallbacks` 接口加字段**(同文件,Task 1 新增的部分): 找到: ```ts export interface StreamParserCallbacks { onText?: (text: string) => void; onBlockEnd?: () => void; } ``` 改为: ```ts export interface StreamParserCallbacks { onText?: (text: string) => void; onBlockEnd?: () => void; onTurnEnd?: (stopReason: string) => void; } ``` **3c. 在 `handleStreamLine` 的 `stream_event` case 加分支。** 找到 `content_block_stop` 分支: ```ts } else if (evt?.type === 'content_block_stop') { state.trackingSkill = false; if (callbacks.onBlockEnd) callbacks.onBlockEnd(); } break; ``` 在其**之后**(仍在 `stream_event` case 内、`break;` 之前)插入: ```ts } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { if (callbacks.onTurnEnd) callbacks.onTurnEnd(evt.delta.stop_reason); } ``` 注意:因为这是 `else if` 链,要把上面那个 `}` 闭合改一下。完整片段应该是: ```ts } else if (evt?.type === 'content_block_stop') { state.trackingSkill = false; if (callbacks.onBlockEnd) callbacks.onBlockEnd(); } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { if (callbacks.onTurnEnd) callbacks.onTurnEnd(evt.delta.stop_reason); } break; ``` **3d. 把 `onTurnEnd` 透传到 parserCallbacks。** 在 `claudeQuery` 内找到: ```ts const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd }; ``` 改为: ```ts const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd, onTurnEnd }; ``` - [ ] **Step 4: 跑测试确认通过** Run: ```bash npm run build && node --test dist/tests/provider.test.js ``` Expected: 8 个测试全 PASS。 - [ ] **Step 5: 提交** ```bash git add src/claude/provider.ts src/tests/provider.test.ts git commit -m "$(cat <<'EOF' feat: provider 暴露 onTurnEnd 回调,按 stop_reason 标记回合类型 handleStreamLine 在收到 message_delta 事件且带 stop_reason 时触发 onTurnEnd(stopReason)。下游可据此区分 'tool_use' 回合(interstitial) 和 'end_turn' / 'max_tokens' 等终态回合(final answer)。 本任务仅暴露信号,不改变任何现有 flush 行为——main.ts 下一任务接入。 Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 3: 创建 `TurnRouter` 状态机 **Files:** - Create: `src/claude/turn-router.ts` - Create: `src/tests/turn-router.test.ts` **Interfaces:** - Consumes: 无(独立模块) - Produces: - `export type MessageRole = 'interstitial' | 'final';` - `export interface RoutedMessage { text: string; role: MessageRole; }` - `export class TurnRouter { constructor(emit: (msg: RoutedMessage) => void); onText(delta: string): void; onTurnEnd(stopReason: string): void; drain(): void; }` 行为契约: - `onText` 累积到内部 `turnBuffer`,不立即 emit。 - `onTurnEnd('tool_use')`:把 `turnBuffer` 作为 `interstitial` emit(trim 后非空才发),清空 `turnBuffer`。 - `onTurnEnd(其他)`:把 `turnBuffer` 追加到 `pendingFinal`(用 `\n\n` 连接非空两端),清空 `turnBuffer`,**不立即 emit**。 - `drain()`:先 emit `pendingFinal` 作为 `final`(非空才发),再 emit 残留 `turnBuffer` 作为 `interstitial`(非空才发),清空两者。 - [ ] **Step 1: 写失败测试** Create `src/tests/turn-router.test.ts`: ```ts import { test } from 'node:test'; import assert from 'node:assert/strict'; import { TurnRouter, type RoutedMessage } from '../claude/turn-router.js'; function newRouter() { const emitted: RoutedMessage[] = []; const router = new TurnRouter((m) => emitted.push(m)); return { router, emitted }; } test('onText 累积不立即 emit', () => { const { router, emitted } = newRouter(); router.onText('hello '); router.onText('world'); assert.deepEqual(emitted, []); }); test('onTurnEnd(tool_use) 把 turnBuffer 作为 interstitial emit', () => { const { router, emitted } = newRouter(); router.onText('让我看一下'); router.onTurnEnd('tool_use'); assert.deepEqual(emitted, [{ text: '让我看一下', role: 'interstitial' }]); }); test('onTurnEnd(end_turn) 不立即 emit,攒到 drain', () => { const { router, emitted } = newRouter(); router.onText('最终答案第一段'); router.onTurnEnd('end_turn'); assert.deepEqual(emitted, []); router.drain(); assert.deepEqual(emitted, [{ text: '最终答案第一段', role: 'final' }]); }); test('多个 end_turn 回合用 \\n\\n 连接成一个 final', () => { const { router, emitted } = newRouter(); router.onText('段一'); router.onTurnEnd('pause_turn'); router.onText('段二'); router.onTurnEnd('end_turn'); router.drain(); assert.deepEqual(emitted, [{ text: '段一\n\n段二', role: 'final' }]); }); test('tool_use 和 end_turn 混合:interstitial 立即发,final 攒到 drain', () => { const { router, emitted } = newRouter(); router.onText('让我查一下'); router.onTurnEnd('tool_use'); // → interstitial 立即 router.onText('找到了。'); router.onText('详细说明...'); router.onTurnEnd('end_turn'); // → final 攒着 router.drain(); // → final 发出 assert.deepEqual(emitted, [ { text: '让我查一下', role: 'interstitial' }, { text: '找到了。详细说明...', role: 'final' }, ]); }); test('空文本回合不产生空消息', () => { const { router, emitted } = newRouter(); router.onTurnEnd('tool_use'); // turnBuffer 空 router.onTurnEnd('end_turn'); // turnBuffer 空 router.drain(); assert.deepEqual(emitted, []); }); test('onTurnEnd 未触发时 drain 也能把残留 turnBuffer 当 interstitial 发出', () => { const { router, emitted } = newRouter(); router.onText('未结束的残留'); router.drain(); assert.deepEqual(emitted, [ { text: '未结束的残留', role: 'interstitial' }, ]); }); test('纯文本 Q&A(无 tool_use,单 end_turn)整段作为 final', () => { const { router, emitted } = newRouter(); const chunks = ['闭包是...', '举个例子...', '总结...']; for (const c of chunks) router.onText(c); router.onTurnEnd('end_turn'); router.drain(); assert.deepEqual(emitted, [ { text: chunks.join(''), role: 'final' }, ]); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: ```bash npm run build && node --test dist/tests/turn-router.test.js ``` Expected: 导入失败 `Cannot find module ../claude/turn-router.js`。 - [ ] **Step 3: 实现 TurnRouter** Create `src/claude/turn-router.ts`: ```ts /** * TurnRouter 把 Claude CLI 的流式输出按"回合"分流: * * - tool_use 回合的文本 → 立即作为 interstitial emit(agent loop 进度) * - 其他 stop_reason(end_turn / max_tokens / stop_sequence / pause_turn / ...) * 的文本 → 攒到 pendingFinal,drain 时一次性作为 final emit * * 设计参考 docs/superpowers/specs/2026-06-20-message-batching-design.md。 * * 本类不做任何 I/O,只决定"何时把哪段文本以什么 role emit"。 * 调用方(main.ts)负责把 RoutedMessage 切分(splitMessage)并发到微信。 */ export type MessageRole = 'interstitial' | 'final'; export interface RoutedMessage { text: string; role: MessageRole; } export class TurnRouter { private turnBuffer = ''; private pendingFinal = ''; constructor(private readonly emit: (msg: RoutedMessage) => void) {} onText(delta: string): void { this.turnBuffer += delta; } onTurnEnd(stopReason: string): void { const text = this.turnBuffer; this.turnBuffer = ''; if (!text.trim()) return; if (stopReason === 'tool_use') { this.emit({ text, role: 'interstitial' }); } else { // end_turn / max_tokens / stop_sequence / pause_turn / 未知值 // 一律当最终答案处理(宁可合并也不丢) this.pendingFinal += this.pendingFinal ? '\n\n' + text : text; } } /** 流结束时调用。先发 final,再 drain 残留 interstitial。 */ drain(): void { if (this.pendingFinal.trim()) { this.emit({ text: this.pendingFinal, role: 'final' }); this.pendingFinal = ''; } if (this.turnBuffer.trim()) { this.emit({ text: this.turnBuffer, role: 'interstitial' }); this.turnBuffer = ''; } } } ``` - [ ] **Step 4: 跑测试确认通过** Run: ```bash npm run build && node --test dist/tests/turn-router.test.js ``` Expected: 8 个测试全 PASS。 - [ ] **Step 5: 提交** ```bash git add src/claude/turn-router.ts src/tests/turn-router.test.ts git commit -m "$(cat <<'EOF' feat: 新增 TurnRouter 状态机,按 stop_reason 分流 interstitial/final 纯逻辑模块,不持 I/O。onText 累积到 turnBuffer;onTurnEnd(tool_use) 立即 emit 为 interstitial,其他 stop_reason 攒到 pendingFinal; drain 时一次性 emit 为 final。 覆盖 8 条路径:累积不 emit / tool_use 立即发 / end_turn 攒到 drain / 多 end_turn 用 \\n\\n 连接 / 混合 / 空回合不产生空消息 / 残留 drain / 纯文本 Q&A 整段 final。 下一个任务把 main.ts 接进来。 Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 4: 把 TurnRouter 接入 `main.ts`,删除死代码 **Files:** - Modify: `src/main.ts`(`sendToClaude` 函数:替换 textBuffer 逻辑、接入 TurnRouter、改 flush 顺序、删除死代码) - Modify: `src/claude/provider.ts`(删除 `QueryOptions.onBlockEnd` 字段、`StreamParserCallbacks.onBlockEnd` 字段、`handleStreamLine` 的 `content_block_stop` 分支调用) - Modify: `src/tests/provider.test.ts`(删除 onBlockEnd 相关测试) **Interfaces:** - Consumes: - Task 1/2 的 `handleStreamLine` / `StreamParserCallbacks`(无 onBlockEnd) - Task 2 的 `QueryOptions.onTurnEnd` - Task 3 的 `TurnRouter` / `RoutedMessage` - Produces: 无新对外接口(`sendToClaude` 仍是内部函数) - [ ] **Step 1: 删除 provider.ts 里的 onBlockEnd(先消提供方,让消费方编译报错暴露出来)** **1a. 修改 `src/claude/provider.ts` 的 `QueryOptions` 接口**,删除: ```ts /** Called when a content block ends — use to flush buffered text. */ onBlockEnd?: () => Promise | void; ``` **1b. 修改 `StreamParserCallbacks`**,删除 `onBlockEnd` 字段: ```ts export interface StreamParserCallbacks { onText?: (text: string) => void; onBlockEnd?: () => void; // ← 删这一行 onTurnEnd?: (stopReason: string) => void; } ``` 变成: ```ts export interface StreamParserCallbacks { onText?: (text: string) => void; onTurnEnd?: (stopReason: string) => void; } ``` **1c. 修改 `handleStreamLine` 的 `content_block_stop` 分支**,不再调用 callbacks: 找到: ```ts } else if (evt?.type === 'content_block_stop') { state.trackingSkill = false; if (callbacks.onBlockEnd) callbacks.onBlockEnd(); } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { ``` 改为: ```ts } else if (evt?.type === 'content_block_stop') { state.trackingSkill = false; } else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) { ``` **1d. 修改 `claudeQuery` 内的 parserCallbacks 构造**,删除 onBlockEnd: 找到: ```ts const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd, onTurnEnd }; ``` 改为: ```ts const parserCallbacks: StreamParserCallbacks = { onText, onTurnEnd }; ``` **1e. 在 `claudeQuery` 函数签名解构里删除 `onBlockEnd`。** 找到: ```ts const { prompt, cwd, resume, model, systemPrompt, images, onText, onBlockEnd, abortController, } = options; ``` 改为(同时加 `onTurnEnd`): ```ts const { prompt, cwd, resume, model, systemPrompt, images, onText, onTurnEnd, abortController, } = options; ``` - [ ] **Step 2: 改 provider.test.ts,删 onBlockEnd 测试** 在 `src/tests/provider.test.ts` 删除: ```ts test('handleStreamLine: content_block_stop 触发 onBlockEnd', () => { let called = 0; handleStreamLine( JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }), freshState(), { onBlockEnd: () => called++ }, ); assert.equal(called, 1); }); ``` 替换为(验证 content_block_stop 仍重置 trackingSkill,但不发回调): ```ts test('handleStreamLine: content_block_stop 重置 trackingSkill,无回调', () => { const state = freshState(); state.trackingSkill = true; let textCalls = 0; handleStreamLine( JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }), state, { onText: () => textCalls++ }, ); assert.equal(state.trackingSkill, false); assert.equal(textCalls, 0); }); ``` - [ ] **Step 3: 编译确认 provider 侧改动不报错(此时 main.ts 还在传 onBlockEnd,预期会报错)** Run: ```bash npm run build 2>&1 | head -20 ``` Expected: 在 `src/main.ts` 报 TypeScript 错误,类似: ``` error TS2322: Type '{ onText: ...; onBlockEnd: ...; onTurnEnd: ...; }' is not assignable to type 'QueryOptions' ... Object literal may only specify known properties, and 'onBlockEnd' does not exist in type 'QueryOptions'. ``` 这是预期的——下一个 step 修 main.ts。 - [ ] **Step 4: 重写 main.ts 的 sendToClaude,接入 TurnRouter** **4a. 加 import。** 在 `src/main.ts` 顶部 imports 区,找到: ```ts import { claudeQuery, type QueryOptions } from './claude/provider.js'; ``` 在其**之后**加: ```ts import { TurnRouter } from './claude/turn-router.js'; ``` **4b. 删除死代码 `endsWithStructuralBoundary`。** 在 `src/main.ts` 的 `sendToClaude` 函数内(约 501-503 行)找到: ```ts /** Check if buffer ends at a structural boundary (double newline or horizontal rule). */ function endsWithStructuralBoundary(text: string): boolean { return /\n\n\s*$/.test(text) || /\n[-*_]{3,}\s*$/.test(text); } ``` 整个函数**删除**。 > 注意:`MIN_BATCH_FLUSH_LEN` 和 `SOFT_FLUSH_LIMIT` 两个常量在 sendToClaude 内部声明,紧跟在 `endsWithStructuralBoundary` 上方。它们会在 step 4c 的整段替换里一并消失(OLD 块包含它们,NEW 块不包含),无需单独处理。 **4c. 替换 sendToClaude 内的流式处理段。** 找到现有的(约 493-591 行): ```ts let textBuffer = ''; let anySent = false; let lastSentTime = Date.now(); const MIN_BATCH_FLUSH_LEN = 30; const SOFT_FLUSH_LIMIT = 3800; /** Check if buffer ends at a structural boundary (double newline or horizontal rule). */ function endsWithStructuralBoundary(text: string): boolean { return /\n\n\s*$/.test(text) || /\n[-*_]{3,}\s*$/.test(text); } // Serial promise chain — each flushText() appends to the chain, no flags needed let flushChain: Promise = Promise.resolve(); function flushText(): Promise { // Capture and clear synchronously to prevent race condition: // new deltas can arrive while the chain awaits sendText, // causing the async callback to clear content it never captured. const captured = textBuffer.trim(); textBuffer = ''; if (!captured) return flushChain; flushChain = flushChain.then(async () => { const chunks = splitMessage(captured); for (let i = 0; i < chunks.length; i++) { try { await sender.sendText(fromUserId, contextToken, chunks[i]); } catch (err) { // Rate-limit exhaustion etc.: put the unsent chunks back at the // front of the buffer so the next flush retries them. Content is // never silently dropped (previously the for-loop aborted here and // the already-cleared buffer lost everything from this chunk on). const remaining = chunks.slice(i).join('\n\n'); textBuffer = remaining + (textBuffer ? '\n\n' + textBuffer : ''); logger.warn('flushText send failed, content retained for retry', { error: err instanceof Error ? err.message : String(err), retainedChunks: chunks.length - i, }); return; } } anySent = true; lastSentTime = Date.now(); }); return flushChain; } // Safety net: send keepalive if nothing was sent for 5 minutes const SILENCE_WARNING_MS = 5 * 60 * 1000; const SILENCE_MESSAGES = [ '我还在处理中,这个问题有点复杂,请再稍等一下', '正在努力干活中,马上就有结果了,请稍等片刻', '有点复杂正在处理,再给我一点时间,很快就好', '快好了别着急,正在收尾阶段,马上给你回复', '还在跑呢,任务量比较大,不过马上就能出结果了', '任务比想象的复杂一些,再等等我,正在全力处理', '正在处理中,进展顺利,再等一会儿就好', '还没完不过已经快了,再给我一分钟就能搞定', '我在认真思考这个问题,请再稍等一会儿', '稍微有点棘手,不过已经快解决了,再等我一下', ]; flushTimer = setInterval(() => { if (Date.now() - lastSentTime > SILENCE_WARNING_MS) { const msg = SILENCE_MESSAGES[Math.floor(Math.random() * SILENCE_MESSAGES.length)]; sender.sendText(fromUserId, contextToken, msg).catch(() => {}); lastSentTime = Date.now(); } }, 2000); const queryOptions: QueryOptions = { prompt, cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()), resume: session.sdkSessionId, model: session.model, systemPrompt: [ '你正在通过微信与用户对话,不是在终端里。不要让用户去终端操作。如果用户需要文件,直接输出文件地址就行,会自动识别解析推送文件到用户的微信中。', config.systemPrompt, ].filter(Boolean).join('\n'), abortController, images, onText: async (delta: string) => { textBuffer += delta; // Flush at structural boundaries (only if buffer is substantial) or when approaching size limit const shouldFlush = (endsWithStructuralBoundary(textBuffer) && textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN) || textBuffer.length > SOFT_FLUSH_LIMIT; if (shouldFlush) { await flushText(); } }, onBlockEnd: () => { if (textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN || textBuffer.length > SOFT_FLUSH_LIMIT) { flushText(); } }, }; ``` 整段替换为: ```ts let anySent = false; let lastSentTime = Date.now(); let pendingRetry = ''; // sendText 失败时未发出的 chunks,下一次 flush 优先重试 // Serial promise chain — each emit appends to the chain, no flags needed let flushChain: Promise = Promise.resolve(); /** 把一段文本切分后串行发到微信。失败时把未发的 chunks 攒到 pendingRetry,下次重试。 */ function emitText(text: string, role: 'interstitial' | 'final'): void { if (!text.trim()) return; flushChain = flushChain.then(async () => { const combined = pendingRetry ? pendingRetry + '\n\n' + text : text; pendingRetry = ''; if (!combined.trim()) return; const chunks = splitMessage(combined); for (let i = 0; i < chunks.length; i++) { try { await sender.sendText(fromUserId, contextToken, chunks[i]); } catch (err) { // Rate-limit exhaustion etc.: put the unsent chunks back so the // next emit retries them. Content is never silently dropped. pendingRetry = chunks.slice(i).join('\n\n'); logger.warn('emitText send failed, content retained for retry', { role, error: err instanceof Error ? err.message : String(err), retainedChunks: chunks.length - i, }); return; } } anySent = true; lastSentTime = Date.now(); }); } const router = new TurnRouter((msg) => emitText(msg.text, msg.role)); // Safety net: send keepalive if nothing was sent for 5 minutes const SILENCE_WARNING_MS = 5 * 60 * 1000; const SILENCE_MESSAGES = [ '我还在处理中,这个问题有点复杂,请再稍等一下', '正在努力干活中,马上就有结果了,请稍等片刻', '有点复杂正在处理,再给我一点时间,很快就好', '快好了别着急,正在收尾阶段,马上给你回复', '还在跑呢,任务量比较大,不过马上就能出结果了', '任务比想象的复杂一些,再等等我,正在全力处理', '正在处理中,进展顺利,再等一会儿就好', '还没完不过已经快了,再给我一分钟就能搞定', '我在认真思考这个问题,请再稍等一会儿', '稍微有点棘手,不过已经快解决了,再等我一下', ]; flushTimer = setInterval(() => { if (Date.now() - lastSentTime > SILENCE_WARNING_MS) { const msg = SILENCE_MESSAGES[Math.floor(Math.random() * SILENCE_MESSAGES.length)]; sender.sendText(fromUserId, contextToken, msg).catch(() => {}); lastSentTime = Date.now(); } }, 2000); const queryOptions: QueryOptions = { prompt, cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()), resume: session.sdkSessionId, model: session.model, systemPrompt: [ '你正在通过微信与用户对话,不是在终端里。不要让用户去终端操作。如果用户需要文件,直接输出文件地址就行,会自动识别解析推送文件到用户的微信中。', config.systemPrompt, ].filter(Boolean).join('\n'), abortController, images, onText: (delta: string) => { router.onText(delta); }, onTurnEnd: (stopReason: string) => { router.onTurnEnd(stopReason); }, }; ``` **4d. 修改流结束后的 flush 顺序。** 找到(约 605-627 行): ```ts // Stop periodic flush and send any remaining buffered content clearInterval(flushTimer); await flushText(); // Send result back to WeChat if (result.text) { if (result.error) { logger.warn('Claude query had error but returned text, using text', { error: result.error }); } sessionStore.addChatMessage(session, 'assistant', result.text); // If nothing was streamed at all (e.g. streaming not supported), send full text now if (!anySent) { const chunks = splitMessage(result.text); for (const chunk of chunks) { await sender.sendText(fromUserId, contextToken, chunk); } } } else if (result.error) { ``` 替换 `clearInterval(flushTimer);` 和 `await flushText();` 这两行(保留后面所有内容不变): ```ts // Stop periodic flush, drain router (final 先于 interstitial), wait for queued sends clearInterval(flushTimer); router.drain(); await flushChain; // Send result back to WeChat if (result.text) { if (result.error) { logger.warn('Claude query had error but returned text, using text', { error: result.error }); } sessionStore.addChatMessage(session, 'assistant', result.text); // If nothing was streamed at all (e.g. streaming not supported), send full text now if (!anySent) { const chunks = splitMessage(result.text); for (const chunk of chunks) { await sender.sendText(fromUserId, contextToken, chunk); } } } else if (result.error) { ``` - [ ] **Step 5: 编译并跑所有测试** Run: ```bash npm run build && npm test ``` Expected: - TypeScript 编译零错误(确认 onBlockEnd 已彻底从 main.ts 移除)。 - 所有测试 PASS:`provider.test.ts`(8 个)+ `turn-router.test.ts`(8 个)。 - [ ] **Step 6: 端到端冒烟** Run: ```bash echo "" | node dist/main.js 2>&1 | head -5 || true ``` Expected: 进程启动、读到 `未找到账号` 或类似的运行期错误(因为没配置),但**不应**报 TypeScript / 模块解析错误。这验证编译产物导入正常。 - [ ] **Step 7: 真实 trace 验证新逻辑依赖的信号确实存在** 重新跑一份 trace,确认 Claude CLI 真实输出里包含新逻辑依赖的 `message_delta` 事件: ```bash mkdir -p /tmp/verify-batching && cd /tmp/verify-batching && \ echo "用三段话解释 JavaScript 闭包" | claude -p - --output-format stream-json --verbose --include-partial-messages --dangerously-skip-permissions 2>/dev/null > trace.jsonl && \ echo "trace 行数: $(wc -l < trace.jsonl)" && \ echo "--- message_delta 事件数(应 >= 1)---" && \ grep -c '"type":"message_delta"' trace.jsonl && \ echo "--- 各 stop_reason 分布 ---" && \ grep -o '"stop_reason":"[^"]*"' trace.jsonl | sort | uniq -c ``` Expected: - trace 行数 > 100。 - 至少 1 个 `message_delta` 事件。 - stop_reason 分布里至少有 1 个 `end_turn`(纯 Q&A 场景)。 > 这一步只验证「我们依赖的信号真实存在」。完整端到端效果验证(消息条数从 N 降到 1)需要装上 daemon 在微信里实测,见下方「验收清单」。 - [ ] **Step 8: 提交** ```bash git add src/main.ts src/claude/provider.ts src/tests/provider.test.ts git commit -m "$(cat <<'EOF' feat: main.ts 接入 TurnRouter,最终答案改为按回合整段推送 把 sendToClaude 原来的单 textBuffer + 段落边界 flush 逻辑替换为 TurnRouter 状态机:onText 只累积不 flush,onTurnEnd(tool_use) 立即 emit 为 interstitial,其他 stop_reason 攒到 pendingFinal,流结束时 router.drain() 一次性 emit 为 final(splitMessage 按 4000 字硬切)。 真实 trace 验证效果: - 纯文本 Q&A(1485 字):8 条 → 1 条 - tool_use + 9121 字长答案:59 条 → 4 条 顺带清理死代码:删除 MIN_BATCH_FLUSH_LEN / SOFT_FLUSH_LIMIT / endsWithStructuralBoundary / onBlockEnd(provider.ts 同步移除字段 和 content_block_stop 回调,测试相应更新)。 限流逻辑、splitMessage、typing、silence warning、文件自动推送 全部不动。emitText 保留了 commit d6d7d62 引入的失败重试语义 (pendingRetry)。 Spec: docs/superpowers/specs/2026-06-20-message-batching-design.md Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## 验收清单(实现完成后人工跑一遍) 参考 spec § 7.3: 1. **纯 Q&A**:微信发"用三段话解释闭包"→ 应收到 **1 条**完整答案(而非现在的 8 条)。 2. **多 tool_use + 长答案**:微信发"分析 src/main.ts 的结构"→ 应收到 1 条 interstitial + N 条最终答案块(按 4000 字切),总条数远少于现在。 3. **Abort**:发任务后立即发 `/stop` → 已发的 interstitial 不丢,部分生成的最终答案按已生成内容推送,无内容丢失。 4. **限流恢复**:观察日志,若偶发 `emitText send failed, content retained for retry`,下一次 emit 应自动重试成功(pendingRetry 机制)。