Files
2026-07-13 12:33:50 +08:00

1234 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 微信消息分块优化 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:** TypeScriptstrict)、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 <noreply@anthropic.com>
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> | 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> | 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 <noreply@anthropic.com>
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 emitagent loop 进度)
* - 其他 stop_reasonend_turn / max_tokens / stop_sequence / pause_turn / ...
* 的文本 → 攒到 pendingFinaldrain 时一次性作为 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 累积到 turnBufferonTurnEnd(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 <noreply@anthropic.com>
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> | 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<void> = Promise.resolve();
function flushText(): Promise<void> {
// 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<void> = 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 只累积不 flushonTurnEnd(tool_use) 立即
emit 为 interstitial,其他 stop_reason 攒到 pendingFinal,流结束时
router.drain() 一次性 emit 为 finalsplitMessage 按 4000 字硬切)。
真实 trace 验证效果:
- 纯文本 Q&A1485 字):8 条 → 1 条
- tool_use + 9121 字长答案:59 条 → 4 条
顺带清理死代码:删除 MIN_BATCH_FLUSH_LEN / SOFT_FLUSH_LIMIT /
endsWithStructuralBoundary / onBlockEndprovider.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 <noreply@anthropic.com>
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 机制)。