Commit 5fbe9a6d1a88ae39e5edc25c4255d451edbf04bd

Authored by yibo
1 parent 41ded07b

feat(stream + docs): /v1/runs SSE 流式 + interim_assistant_callback + 文档更新

代码(server/services/wechat/auto-reply.js):
- 切到 /v1/runs + GET /events SSE 长连接替代 /v1/chat/completions 同步阻塞
- 监听新事件:
    message.complete  → 主路径多步消息(每 turn 末尾的可见 content)
    message.delta     → content tokens 兜底
    reasoning.delta   → 默认丢弃;STREAM_REASONING=true 时加 🧠 前缀
    reasoning.available → 同上
    tool.started      → flush 累积;STREAM_TOOL_PROGRESS=true 时发 🔧 进度
    run.completed     → 最终 output utterance + resolve
- 串行 sendChain:第一条带 quote+@,后续仅文本;A11y 完成前不发下一条
- 诊断日志节流(高频事件 5s 一次)
- log() 去掉 fs.appendFileSync(sync I/O 在高频流式下会卡死 node)

Hermes upstream 补丁(phone 上 ~/hermes-agent/gateway/platforms/api_server.py):
- 加 _reasoning_cb + reasoning_callback wire-up(_create_agent 签名 / AIAgent 构造)
- 加 _interim_cb + interim_assistant_callback wire-up(同上)
- /v1/runs 启动 agent 时把两个新回调都传进去
- 发现:原 hermes /v1/runs 默认只挂 stream_delta_callback + tool_progress_callback,
  漏了 gateway 平台用的另外几个关键回调,导致 /v1/runs 客户端只能收到最终一条回复

实测 Kimi 限制:
- Kimi-k2.6 在 hermes agentic mode 下 assistant_message.content 永远空
- 所有内容在 reasoning_content → interim_assistant_callback 因 content 为空 return → 不 fire
- 短期 workaround: STREAM_REASONING=true
- 长期: prompt 工程 / SOUL.md 优化

HANDOFF.md:
- 新章节 §十一 A11y 显式状态机(5 状态 + classifier + dispatch)
- 新章节 §十二 流式多步消息(SSE 事件表 + hermes 补丁说明 + Kimi 限制 + ENV flag 表)
- 已知待办从 6 条扩到 9 条(新增 Kimi content 沉默、长消息分段、群消息合流、流式重试、多模型 fallback、SOUL.md 优化等)
- 避雷清单加 4 条今天新踩的坑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
server/services/wechat/HANDOFF.md
... ... @@ -3,7 +3,7 @@
3 3 > **谁会读这份文档**:刚接手这台 Pixel 部署的 claude-run / 开发者,没有上下文。
4 4 > **目标**:5 分钟搞清楚正在跑什么、文件在哪、怎么改、怎么验证。
5 5 >
6   -> **生成时间**:2026-05-24,由 Mac 端 Claude 部署后写入。请定期核对实际状态,
  6 +> **生成时间**:2026-05-25(v2,加 FSM + 流式 + hermes 补丁章节)。请定期核对实际状态,
7 7 > 因为本文件不会随代码自动更新。
8 8  
9 9 ---
... ... @@ -261,17 +261,21 @@ pkill -f auto-reply.js
261 261  
262 262 ## 七、已知待办(任选)
263 263  
  264 +**今日已完成**(5/24):群昵称查询 / A11y 长按引用 + @ 回复 / wake-lock + keepalive 守护 / 清理老凭据 / Frida 残留清理 / A11y FSM 重构 / 流式 SSE / hermes 补丁。
  265 +
264 266 按优先级:
265 267  
266   -1. **群聊昵称查询**:`android-wechat-tracker-sqlcipher.js` 的 `getContactNickname` 对 `@chatroom` 后缀直接 return wxid,导致 A11y 搜不到群名。
267   - - 改法:删掉 `if (wxid.endsWith('@chatroom')) return wxid;` 分支,让它走通用 rcontact 查询
268   -2. **群聊引用 + @ 回复**:扩 `HermesAccessibilityService.java`,加长按消息 → 选"引用" → 输入 `@` → 选成员的 UI 自动化
269   -3. **`termux-wake-lock` 启用**:保证息屏深 Doze 时 `termux-notification-list` 仍响应。
270   - - `am startservice -a com.termux.service_wake_lock com.termux/.app.TermuxService`
271   - - 写进 `keepalive.sh`
272   -4. **清理 auth.json 老凭据**:删 `credential_pool["kimi-coding"]` 里 last_status=exhausted 的项
273   -5. **杀 Frida 残留**:`pkill frida-server` + `rm -rf ~/.frida_queue/`
274   -6. **重命名/移除已废弃文件**:`android-wechat-sender-frida.js` 等已加废弃注释,可改名为 `*.deprecated.js` 或直接删
  268 +1. **A11y service 重命名**:`android-wechat-sender-frida.js` 等已加废弃注释,可改名为 `*.deprecated.js` 或删除
  269 +2. **Kimi content 沉默问题**:模型在 hermes agentic mode 下 `content` 永远空,所有内容在 `reasoning_content`,导致 `interim_assistant_callback` 无法 fire。当前两条路:
  270 + - 短期:开 `STREAM_REASONING=true`,把 reasoning 加 `🧠` 前缀作为思考过程发给用户
  271 + - 长期:改 hermes system prompt 或 SOUL.md,让 Kimi 在 content 字段写"对用户的话"而不是只塞 reasoning_content
  272 +3. **群里长消息分段**:单条 utterance > 500 字会被截断(`maxReplyLength`),应该改成自动分段成多条
  273 +4. **照片 / 文件消息**:tracker 只识别 type=1 文本,type=3 (图片) 等忽略
  274 +5. **群聊消息去重 / 合流**:短时间内多条新消息 → bot 可能每条都触发回复,体验差。需要 debounce + 合并
  275 +6. **流式重试 / 部分输出落地**:当前 run 失败丢全部进度,应该让已 emit 的 utterance 保留为状态
  276 +7. **多模型 fallback**:当前只 Moonshot CN。Anthropic OAuth 还在 auth.json 但已 401。补一个有效的 fallback provider
  277 +8. **Hermes SOUL.md 优化**:当前是默认描述,可针对微信场景定制 prompt 让回复更"小火子"
  278 +9. **A11y service 故障自愈**:发送失败时(如 chat 找不到)能不能更智能恢复(如点 BACK / 切微信 tab)
275 279  
276 280 ---
277 281  
... ... @@ -292,6 +296,9 @@ pkill -f auto-reply.js
292 296 | `[HERMES] API timeout` / `socket hang up` 在 turns=9 多上下文场景下偶发 | Moonshot kimi-k2.6 长 context LLM call 可达 60s+,旧的 `HERMES_TIMEOUT=120000` 偶发不够 | 启动 env 设 `HERMES_TIMEOUT=180000`(3 分钟),keepalive.sh 已内联 |
293 297 | A11y 状态机 `CHAT_LIST → click → WRONG_CHAT → BACK` 循环死磕直到 cap=6 给出 | 群聊页 `obn` (id=`/obn`) 标题带成员数后缀(如 `"宜创AI全员群(60)"`),而 broadcast 传的 `toUserDisplayName` 是 `"宜创AI全员群"`(chatroom.displayname 不带后缀),`equals` 永远 false | `classifyState` 拿 `obn.text` 后用正则 `\\(\\d+\\)$` 剥成员数后缀再比对(带/不带都接受) |
294 298 | WeChat `LauncherUI` 不在主"会话" tab,`FLAG_ACTIVITY_CLEAR_TASK` 也无法回去 | LauncherUI 是 fragment 容器,CLEAR_TASK 重置 task 但不重置内部 fragment 状态。从聊天页或其它主 tab 调 startActivity 不会跳回会话列表 | 状态机的 `NO_CHAT_VIEW` 处理优先 `findBottomNavTab("微信")` 点击(按 Y 坐标取最下方那个,区分标题栏同名 text),fallback `GLOBAL_ACTION_BACK` |
  299 +| Hermes `/v1/runs` 长时间一片寂静、无任何 `[UTTER]` 事件输出,最后 `run.completed` 一次性给一大段总结 | Kimi-k2.6 在 hermes agentic mode 下 `assistant_message.content` 永远空,所有内容在 `reasoning_content`;`message.delta`/`interim_assistant_callback`/`message.complete` 都不 fire;只有 `reasoning.delta` 大量喷涌 | 短期开 `STREAM_REASONING=true` 让 reasoning 加 `🧠` 前缀作 utterance;长期改 hermes system prompt 让 Kimi 写 content 字段。详见 §十二.4 |
  300 +| auto-reply node 进程被 keepalive 反复重启,CPU 占用高、log 文件被洪水般写入 | 流式场景下高频 `message.delta`/`reasoning.delta` 事件触发同步 `fs.appendFileSync` + 每条都 console.log,sync I/O 卡死 node;keepalive 检测心跳超时 → kill → restart | `log()` 去掉 `appendFileSync`(stdout 已由 start 脚本重定向到 log file);诊断事件加节流(高频事件 5 秒打一次) |
  301 +| Hermes `/v1/runs` 只输出"最终一条回复",没多步进度 | hermes 默认 `/v1/runs` 只挂 `stream_delta_callback` + `tool_progress_callback`,缺 `reasoning_callback` + `interim_assistant_callback`(gateway 平台用 7+ 个回调) | 补丁 `gateway/platforms/api_server.py`,在 `_create_agent` 签名 + AIAgent 构造 + run handler 三处都加上缺的两个回调,emit `reasoning.delta` / `message.complete` SSE 事件。详见 §十二.3 |
295 302  
296 303 ---
297 304  
... ... @@ -341,4 +348,95 @@ State reset
341 348  
342 349 ---
343 350  
  351 +## 十一、A11y 状态机(5/24 重构)
  352 +
  353 +旧代码用嵌套 if + ad-hoc relaunch 处理"目标聊天 / 错误聊天 / 主界面 / 微信不在前台"等多种状态,边角情况覆盖不全(特别是 LauncherUI fragment 容器 + CLEAR_TASK 不重置内部 fragment)。
  354 +
  355 +新实现:**显式状态机**(`HermesAccessibilityService.java::driveStateMachine`)
  356 +
  357 +| 状态 | classifier 特征 | 动作 |
  358 +|------|-----------------|------|
  359 +| `TARGET_CHAT` | `bkk` + `obn.text` 等于 displayName(剥末尾 `(数字)` 后) | `performTypeAndSend` 直走 |
  360 +| `WRONG_CHAT` | `bkk` + `obn.text` 不匹配 | `GLOBAL_ACTION_BACK` → 再 classify |
  361 +| `CHAT_LIST` | 无 `bkk`,`kbq` 多个 | `findChatListRow` 点 cj1 → 再 classify |
  362 +| `NO_CHAT_VIEW` | 无 `bkk` 无 `kbq` 但在 com.tencent.mm | `findBottomNavTab("微信")` 点底部 tab,找不到就 BACK |
  363 +| `OUTSIDE_WECHAT` | `root.pkg != com.tencent.mm` | `startActivity(LauncherUI)` → 再 classify |
  364 +
  365 +- 入口:`onReceive` 拉 LauncherUI 后 3.5s 调 `driveStateMachine(0)`
  366 +- `MAX_STATE_TRANSITIONS = 6` 防死循环
  367 +- 关键修复:**chat list 优先于搜索**(避开"丁鹏、🍋火星人、火星人"被搜索解读成"创建包含 3 人的新群"歧义)
  368 +- 关键修复:**obn 标题剥末尾 `(\d+)`**(群聊页 obn 是"宜创AI全员群(60)" 含成员数后缀)
  369 +
  370 +---
  371 +
  372 +## 十二、流式多步消息(/v1/runs SSE,5/24 实现 + hermes 补丁)
  373 +
  374 +### 12.1 架构
  375 +
  376 +```
  377 +auto-reply.js hermes (/v1/runs) Moonshot CN
  378 + │ │ │
  379 + ├─POST /v1/runs─────────────────→│ 返回 run_id │
  380 + │ { input, conversation_history,│ │
  381 + │ session_id, instructions } │ │
  382 + │←───────────── run_id ──────────┤ │
  383 + │ │ │
  384 + ├─GET /v1/runs/{id}/events ─────→│ 长连 SSE │
  385 + │ │←──API call #1───────────┤
  386 + │←─message.delta / reasoning.delta / │
  387 + │ tool.started / message.complete │
  388 + │ │ ...iterating tools... │
  389 + │ │←──API call #N───────────┤
  390 + │←──run.completed ───────────────┤ │
  391 +```
  392 +
  393 +每个事件触发一段独立 utterance(微信单独发送一条消息),实现"hermes 多步思考 → 微信多条回复"。
  394 +
  395 +### 12.2 SSE 事件类型(按消费优先级)
  396 +
  397 +| 事件 | payload | auto-reply 处理 |
  398 +|------|---------|----------------|
  399 +| `message.complete` | `{ text, already_streamed }` | **主路径**:每 turn 结束 / tool 前,emit text 作为一条 utterance(已 strip `<think>` 块,是用户可见内容) |
  400 +| `message.delta` | `{ delta }` | 累积到 textBuf,tool.started 时兜底 flush |
  401 +| `reasoning.delta` | `{ text }` | 默认丢弃(模型内心独白);`STREAM_REASONING=true` 时累积 + 加 `🧠` 前缀 emit |
  402 +| `reasoning.available` | `{ text }` | 同上,仅在 reasoning.delta 没累积时用 |
  403 +| `tool.started` | `{ tool, preview }` | flush 任何累积;`STREAM_TOOL_PROGRESS=true` 时额外 emit `🔧 工具: 参数` |
  404 +| `tool.completed` | `{ tool, duration, error }` | 忽略 |
  405 +| `run.completed` | `{ output, usage }` | emit output 作为最终 utterance + resolve Promise |
  406 +| `run.failed` | `{ error }` | reject Promise |
  407 +
  408 +### 12.3 Hermes API server 补丁(已应用到 phone)
  409 +
  410 +`/v1/runs` **upstream** 默认只挂 `stream_delta_callback`(content tokens)+ `tool_progress_callback`,**缺**:
  411 +- `reasoning_callback` → Kimi/Moonshot 的 chain-of-thought 走不出来
  412 +- `interim_assistant_callback` → TUI/Telegram/Discord 用的多步消息机制
  413 +
  414 +我们在 `~/hermes-agent/gateway/platforms/api_server.py` 加了两个回调 + 在 `_create_agent` 签名 / AIAgent 构造调用都补了参数。改动以注释 `# Forward reasoning_content...` 和 `# Forward interim assistant messages...` 标识。
  415 +
  416 +**改动文件**:phone 上 `~/hermes-agent/gateway/platforms/api_server.py`(备份 `.bak`)。Mac repo 上没同步——hermes-agent 不在我们的 git repo 里。如果 hermes 升级要重新补丁。
  417 +
  418 +### 12.4 Kimi-k2.6 在 Hermes agentic mode 的限制 ⚠️
  419 +
  420 +实测发现:**Kimi-k2.6 在 hermes 系统提示下,`assistant_message.content` 永远是空字符串**,所有可见内容塞 `reasoning_content`。导致:
  421 +
  422 +- `message.delta` 计数 = 0(content 字段没 token)
  423 +- `interim_assistant_callback` 内部检查 `visible = strip_think(content).strip()` 为空就 return → 不 fire
  424 +- `message.complete` 事件也不出现
  425 +- 用户只能在 `run.completed.output` 时看到最终一段总结
  426 +
  427 +短期 workaround:**`STREAM_REASONING=true`**(默认 OFF)让 chain-of-thought 加 `🧠` 前缀作为 utterance 发出去。
  428 +
  429 +长期方案:改 hermes system prompt / SOUL.md 让 Kimi 把"对用户说的话"写到 content 字段。
  430 +
  431 +### 12.5 流式相关 ENV flag
  432 +
  433 +| 变量 | 默认 | 作用 |
  434 +|------|------|------|
  435 +| `STREAM_TOOL_PROGRESS` | false | 每个 `tool.started` 发一条 `🔧 工具: 参数` 进度消息 |
  436 +| `STREAM_REASONING` | false | 把 Kimi 的 reasoning_content(内心独白)加 `🧠` 前缀作为 utterance 发 |
  437 +| `HERMES_TIMEOUT` | `180000` (旧) → `600000` (新) | SSE 长连 timeout,长 agentic run 经常 3-5min |
  438 +| `HISTORY_CONTEXT_SIZE` | 8 | 喂给 hermes 的微信历史消息条数 |
  439 +
  440 +---
  441 +
344 442 **END OF HANDOFF** — 改动这份文档时记得更新顶部生成时间。
... ...
server/services/wechat/auto-reply.js
... ... @@ -54,6 +54,15 @@ const CONFIG = {
54 54 // 上下文长度:每次调 hermes 时回看多少条最近消息(含本次触发的那条)
55 55 historyContextSize: parseInt(process.env.HISTORY_CONTEXT_SIZE, 10) || 8,
56 56  
  57 + // 流式行为:模型沉默 agentic 调用时,是否发"🔧 调用 X"进度消息
  58 + // 默认 OFF(避免群里刷屏);开了之后每个 tool.started 都会发一条进度消息
  59 + streamToolProgress: process.env.STREAM_TOOL_PROGRESS === 'true',
  60 +
  61 + // 是否把 reasoning_content(模型内心独白)作为消息发给用户
  62 + // 默认 OFF — reasoning 是模型自言自语("用户要求..." 第三人称),
  63 + // 直接发给用户极其诡异。开了之后会带 "🧠 " 前缀作为思考进度发。
  64 + streamReasoning: process.env.STREAM_REASONING === 'true',
  65 +
57 66 // 通知监听(息屏兜底)
58 67 notificationPollIntervalMs: 3000,
59 68 notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false',
... ... @@ -125,10 +134,9 @@ class ReplyState {
125 134  
126 135 function log(...args) {
127 136 const line = `[${new Date().toISOString()}] ${args.join(' ')}`;
  137 + // start-ar.sh 已经把 stdout 重定向到 auto-reply.log,console.log 即写入文件。
  138 + // 不再额外 fs.appendFileSync — 同步 I/O 在高频流式场景下会卡死 node。
128 139 console.log(line);
129   - try {
130   - fs.appendFileSync(CONFIG.logFile, line + '\n');
131   - } catch {}
132 140 }
133 141  
134 142 // ═══════════════════════════════════════════════════════════════
... ... @@ -207,34 +215,57 @@ async function callHermesRunStream(messages, sessionId, onUtterance) {
207 215 timeout: CONFIG.hermesTimeoutMs,
208 216 }, res => {
209 217 let buf = '';
210   - let textBuf = '';
  218 + let textBuf = ''; // 累积 message.delta(assistant content tokens)
  219 + let reasoningBuf = ''; // 累积 reasoning.delta(Kimi 的 chain-of-thought tokens)
211 220 let utteranceIdx = 0;
212   - let sendChain = Promise.resolve(); // 串行队列:每个 flush 在前一个完成后执行
  221 + let sendChain = Promise.resolve(); // 串行队列:每个 utterance 在前一个完成后执行
213 222 let finalResult = {};
214 223  
215   - const flush = (isFinal) => {
216   - const text = textBuf.trim();
217   - textBuf = '';
218   - if (!text) return;
219   - const idx = utteranceIdx++;
220   - // 关键:链式 append,不等待
221   - sendChain = sendChain
222   - .then(() => onUtterance(text, idx, !!isFinal))
223   - .catch(e => log('[onUtterance error]', e.message));
224   - };
225   -
226   - // diagnostic counters
227   - const evCount = { 'message.delta': 0 };
228   - let lastDiagLogAt = Date.now();
  224 + // diagnostic counters — message.delta / reasoning.delta 都是高频 token 流,
  225 + // 必须节流否则 600+ events × sync 文件 IO 会卡死 node
  226 + const evCount = {};
  227 + let lastDiagLogAt = 0;
  228 + const HIGH_FREQ_EVENTS = new Set(['message.delta', 'reasoning.delta']);
229 229 const diagLog = (evt) => {
230 230 evCount[evt] = (evCount[evt] || 0) + 1;
231 231 const now = Date.now();
232   - if (evt !== 'message.delta' || now - lastDiagLogAt > 10000) {
233   - log('[STREAM EV]', evt, 'counts=', JSON.stringify(evCount), 'textBuf.len=' + textBuf.length);
  232 + const isHighFreq = HIGH_FREQ_EVENTS.has(evt);
  233 + if (!isHighFreq || now - lastDiagLogAt > 5000) {
  234 + log('[STREAM EV]', evt, 'counts=', JSON.stringify(evCount),
  235 + 'textBuf.len=' + textBuf.length, 'reasoningBuf.len=' + reasoningBuf.length);
234 236 lastDiagLogAt = now;
235 237 }
236 238 };
237 239  
  240 + // 跟踪上一次实际发出去的文本,避免 reasoning.available 和 run.completed 重复发同样内容
  241 + let lastEmitted = '';
  242 + const emit = (text, isFinal) => {
  243 + const trimmed = (text || '').trim();
  244 + if (!trimmed) return;
  245 + // 简单 dedupe:完全相同 / 完全包含 / 被包含都跳过
  246 + if (trimmed === lastEmitted) return;
  247 + if (lastEmitted && (trimmed.includes(lastEmitted) || lastEmitted.includes(trimmed))) return;
  248 + lastEmitted = trimmed;
  249 + const idx = utteranceIdx++;
  250 + sendChain = sendChain
  251 + .then(() => onUtterance(trimmed, idx, !!isFinal))
  252 + .catch(e => log('[onUtterance error]', e.message));
  253 + };
  254 +
  255 + // 重新定义 flush 让它走 emit 通道
  256 + const flushTextBuf = (isFinal) => {
  257 + const t = textBuf.trim();
  258 + textBuf = '';
  259 + if (t) emit(t, isFinal);
  260 + };
  261 +
  262 + // 把累积的 reasoning(Kimi chain-of-thought)作为一段独立 utterance flush
  263 + const flushReasoningBuf = (isFinal) => {
  264 + const t = reasoningBuf.trim();
  265 + reasoningBuf = '';
  266 + if (t) emit(t, isFinal);
  267 + };
  268 +
238 269 res.setEncoding('utf-8');
239 270 res.on('data', chunk => {
240 271 buf += chunk;
... ... @@ -248,17 +279,61 @@ async function callHermesRunStream(messages, sessionId, onUtterance) {
248 279 try { ev = JSON.parse(line.substring(6)); } catch (_) { continue; }
249 280 diagLog(ev.event);
250 281 switch (ev.event) {
  282 + case 'message.complete':
  283 + // 🎯 主路径:interim_assistant_callback(gateway 平台用的事件)
  284 + // 在每个 turn 末尾、tool 之前 fire,携带模型本轮**给用户看的**
  285 + // 可见 content(已 strip <think> 块)。这是真正的"多步消息"信号。
  286 + // already_streamed=true 表示 content 已通过 message.delta 流过,避免重复
  287 + if (!ev.already_streamed) {
  288 + emit(ev.text, false);
  289 + // 同 turn 的 message.delta 已被 interim 覆盖,重置 textBuf
  290 + textBuf = '';
  291 + }
  292 + // reasoning 同 turn 的 chain-of-thought 已落地,清掉避免泄露给用户
  293 + reasoningBuf = '';
  294 + break;
251 295 case 'message.delta':
  296 + // 模型 content tokens — 主要靠 message.complete 拿干净文本,
  297 + // message.delta 累积仅做兜底(如果 interim 没 fire)
252 298 textBuf += (ev.delta || '');
253 299 break;
  300 + case 'reasoning.delta':
  301 + // Kimi/Moonshot 的 chain-of-thought tokens — 模型内心独白,
  302 + // 默认不发给用户(会出现第三人称"用户要求..."的诡异内容)。
  303 + // STREAM_REASONING=true 时累积,flush 时加 "🧠 " 前缀
  304 + if (CONFIG.streamReasoning) {
  305 + reasoningBuf += (ev.text || '');
  306 + }
  307 + break;
  308 + case 'reasoning.available':
  309 + // 整段 turn 末尾的 thinking,同样默认不发
  310 + if (CONFIG.streamReasoning && !reasoningBuf.trim() && ev.text) {
  311 + emit('🧠 ' + ev.text, false);
  312 + }
  313 + break;
254 314 case 'tool.started':
255   - // turn 边界:上一段 assistant 文本完成
256   - flush(false);
  315 + // turn 边界兜底:interim_assistant_callback 已 emit 过本轮 content,
  316 + // textBuf 仅在 interim 未触发时用
  317 + if (CONFIG.streamReasoning) {
  318 + const r = reasoningBuf.trim();
  319 + reasoningBuf = '';
  320 + if (r) emit('🧠 ' + r, false);
  321 + }
  322 + flushTextBuf(false);
  323 + if (CONFIG.streamToolProgress) {
  324 + const toolName = ev.tool || '?';
  325 + const preview = (ev.preview || '').toString().split('\n')[0].substring(0, 60);
  326 + emit(preview ? `🔧 ${toolName}: ${preview}` : `🔧 ${toolName}`, false);
  327 + }
257 328 break;
258 329 case 'run.completed':
259   - flush(true);
  330 + // 收尾:emit 最终 output(如果跟 last interim 重复会被 dedupe 吃掉)
  331 + if (ev.output) {
  332 + emit(ev.output, true);
  333 + } else {
  334 + flushTextBuf(true);
  335 + }
260 336 finalResult = { output: ev.output, usage: ev.usage };
261   - // 等串行队列 drain 完再 resolve
262 337 sendChain.then(() => resolve(finalResult));
263 338 break;
264 339 case 'run.failed':
... ... @@ -267,7 +342,7 @@ async function callHermesRunStream(messages, sessionId, onUtterance) {
267 342 case 'run.cancelled':
268 343 reject(new Error('run cancelled'));
269 344 break;
270   - // tool.completed / reasoning.available / approval.* 忽略
  345 + // tool.completed / approval.* 忽略
271 346 }
272 347 }
273 348 }
... ...