aicodeswitch 5.2.13 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,15 +28,27 @@ const events_1 = require("events");
28
28
  const activity_extractor_1 = require("./activity-extractor");
29
29
  const session_meta_1 = require("./session-meta");
30
30
  const notifier_1 = require("../notifier");
31
- const ACTIVE_WINDOW_MS = 60000; // 最近 60s 内有活动active
32
- const IDLE_WINDOW_MS = 10 * 60000; // 10min 内有活动但超过 active 窗口 → idle;超出 → completed
33
- const SWEEP_INTERVAL_MS = 15000; // 每 15s 扫描一次状态迁移
31
+ const IDLE_WINDOW_MS = 10 * 60000; // 无在途请求后,10min idle;超出 completed
32
+ const SWEEP_INTERVAL_MS = 15000; // 15s 扫描一次状态迁移(含在途陈旧度检查)
34
33
  const MAX_EVENTS_PER_SESSION = 200;
35
34
  const MAX_GLOBAL_EVENTS = 500;
36
35
  const RECENT_WINDOW_FOR_STATS_MS = 60000;
36
+ // SSE 流「静默」判定:流已开始产出后,超过该时长无新 chunk 视为停滞(用户规范:30s)。
37
+ const SSE_SILENCE_MS = 30000;
38
+ // Thinking(思考)请求的静默上限:思考类模型在首 Token 后、正式内容前常有较长静默思考期
39
+ // (部分上游不流式下发 thinking token),需比普通 SSE 30s 更宽松,避免误判停滞。
40
+ const THINKING_SILENCE_MS = 3 * 60000;
41
+ // 同步请求 / SSE 首 Token 前的「最长存活」保护:服务中断导致请求永不返回时,
42
+ // 超过该时长强制结束(用户规范:5min),避免会话永远卡在「进行中」。
43
+ const SYNC_MAX_MS = 5 * 60000;
37
44
  // 「一轮结束」OS 通知冷却:同一会话在此窗口内的多次 active→idle(典型来自主轮之后的
38
45
  // 后台/计数/compact 等续发请求,每个 end_turn 都会再触发一次迁移)只弹一次,避免重复打扰。
39
46
  const NOTIFY_COOLDOWN_MS = 60000;
47
+ // 「任务结束」防抖延迟(同时作用于:展示状态保持 active 的时长 + 结束通知的弹前等待):
48
+ // 检测到任务结束时,先保持「进行中」满该时长,期满且无新请求才落实「空闲」并弹通知;若期间发起新
49
+ // 主请求(用户/客户端马上继续下一轮),lastActivityAt 刷新 + 取消挂起通知 → 继续保持「进行中」。
50
+ // 避免「每轮结束都闪一下空闲 / 都弹通知」,也兜底吸收 end_turn 误判。
51
+ const NOTIFY_DEBOUNCE_MS = 8000;
40
52
  // 启动重建:只回填最近 1h 内有活动的会话,每会话最多回填 N 条事件,避免重复解析大量历史分片
41
53
  const REBUILD_SINCE_MS = 60 * 60000;
42
54
  const REBUILD_EVENTS_PER_SESSION = 30;
@@ -83,17 +95,29 @@ class AgentMapService extends events_1.EventEmitter {
83
95
  value: false
84
96
  });
85
97
  // 阈值可调(供测试/配置覆盖)
86
- Object.defineProperty(this, "activeWindowMs", {
98
+ Object.defineProperty(this, "idleWindowMs", {
87
99
  enumerable: true,
88
100
  configurable: true,
89
101
  writable: true,
90
- value: ACTIVE_WINDOW_MS
102
+ value: IDLE_WINDOW_MS
91
103
  });
92
- Object.defineProperty(this, "idleWindowMs", {
104
+ Object.defineProperty(this, "sseSilenceMs", {
93
105
  enumerable: true,
94
106
  configurable: true,
95
107
  writable: true,
96
- value: IDLE_WINDOW_MS
108
+ value: SSE_SILENCE_MS
109
+ });
110
+ Object.defineProperty(this, "thinkingSilenceMs", {
111
+ enumerable: true,
112
+ configurable: true,
113
+ writable: true,
114
+ value: THINKING_SILENCE_MS
115
+ });
116
+ Object.defineProperty(this, "syncMaxMs", {
117
+ enumerable: true,
118
+ configurable: true,
119
+ writable: true,
120
+ value: SYNC_MAX_MS
97
121
  });
98
122
  this.setMaxListeners(50);
99
123
  }
@@ -131,9 +155,14 @@ class AgentMapService extends events_1.EventEmitter {
131
155
  continue;
132
156
  const lastRequestAt = s.lastRequestAt || s.firstRequestAt || now;
133
157
  const { status, reason } = this.inferStatus({
134
- lastRequestAt,
158
+ lastActivityAt: lastRequestAt,
135
159
  lastStatusCode: s.lastStatusCode,
136
160
  inFlight: 0,
161
+ streamingInFlight: 0,
162
+ thinkingInFlight: 0,
163
+ streamFirstChunkAt: 0,
164
+ inFlightSince: 0,
165
+ lastTurnEnd: null,
137
166
  now,
138
167
  });
139
168
  this.states.set(s.id, {
@@ -145,6 +174,10 @@ class AgentMapService extends events_1.EventEmitter {
145
174
  lastRequestAt,
146
175
  requestCount: s.requestCount || 0,
147
176
  totalTokens: s.totalTokens || 0,
177
+ inputTokens: s.inputTokens || 0,
178
+ outputTokens: s.outputTokens || 0,
179
+ runningInputTokens: 0,
180
+ runningOutputTokens: 0,
148
181
  lastToolName: s.lastToolName,
149
182
  lastActivitySummary: s.lastActivitySummary,
150
183
  lastStatusCode: s.lastStatusCode,
@@ -152,6 +185,13 @@ class AgentMapService extends events_1.EventEmitter {
152
185
  status,
153
186
  statusReason: reason,
154
187
  inFlight: 0,
188
+ lastActivityAt: lastRequestAt,
189
+ streamingInFlight: 0,
190
+ thinkingInFlight: 0,
191
+ streamFirstChunkAt: 0,
192
+ inFlightSince: 0,
193
+ notifiedForTurn: false,
194
+ notifyTimer: null,
155
195
  });
156
196
  }
157
197
  this.broadcastStats();
@@ -283,7 +323,13 @@ class AgentMapService extends events_1.EventEmitter {
283
323
  }
284
324
  });
285
325
  }
286
- // ============ 在途请求注册表 ============
326
+ // ============ 在途请求注册表 + 可靠活动追踪 ============
327
+ /**
328
+ * 请求开始:在途计数 + 活动时钟刷新。
329
+ * - background=true(count_tokens / compact / 后台请求):刷新活动、计入在途,用于「是否还活着」判定;
330
+ * 但不重置 notifiedForTurn —— 这样主任务结束后紧跟的后台请求不会再次触发结束通知。
331
+ * - background=false(主轮请求):视为「新一轮用户任务」,重置 notifiedForTurn,允许结束时再弹一次通知。
332
+ */
287
333
  startRequest(sessionId, agent, opts) {
288
334
  if (!sessionId || sessionId === '-')
289
335
  return;
@@ -301,14 +347,38 @@ class AgentMapService extends events_1.EventEmitter {
301
347
  lastRequestAt: now,
302
348
  requestCount: 0,
303
349
  totalTokens: 0,
350
+ inputTokens: 0,
351
+ outputTokens: 0,
352
+ runningInputTokens: 0,
353
+ runningOutputTokens: 0,
304
354
  status: 'active',
305
355
  statusReason: 'in-flight',
306
356
  inFlight: 0,
357
+ lastActivityAt: now,
358
+ streamingInFlight: 0,
359
+ thinkingInFlight: 0,
360
+ streamFirstChunkAt: 0,
361
+ inFlightSince: now,
362
+ notifiedForTurn: false,
363
+ notifyTimer: null,
307
364
  };
308
365
  this.states.set(sessionId, st);
309
366
  }
367
+ if (st.inFlight === 0) {
368
+ // 新的一批在途开始:记录起跑时刻(同步请求 5min 超时基线)
369
+ st.inFlightSince = now;
370
+ st.streamFirstChunkAt = 0;
371
+ }
310
372
  st.inFlight += 1;
373
+ if (opts === null || opts === void 0 ? void 0 : opts.thinking)
374
+ st.thinkingInFlight += 1;
311
375
  st.lastRequestAt = now;
376
+ st.lastActivityAt = now;
377
+ if (!(opts === null || opts === void 0 ? void 0 : opts.background)) {
378
+ // 主轮(用户发起的新任务):会话再次活跃 → 取消上一轮可能挂起的结束通知(防抖),并重置通知标记
379
+ this.cancelNotifyTimer(st);
380
+ st.notifiedForTurn = false;
381
+ }
312
382
  // 有在途请求必然 active
313
383
  if (st.status !== 'active') {
314
384
  st.status = 'active';
@@ -316,17 +386,95 @@ class AgentMapService extends events_1.EventEmitter {
316
386
  }
317
387
  this.emitSession(st);
318
388
  }
319
- endRequest(sessionId) {
389
+ /** SSE 流真正开始(pipeline 已建立)。记一个在途流,并刷新活动时钟(首 Token 仍待 heartbeat 标记)。 */
390
+ markStreaming(sessionId) {
391
+ if (!sessionId || sessionId === '-')
392
+ return;
393
+ const st = this.states.get(sessionId);
394
+ if (!st)
395
+ return;
396
+ st.streamingInFlight += 1;
397
+ st.lastActivityAt = Date.now();
398
+ }
399
+ /**
400
+ * SSE chunk 心跳:每流经一个下游 chunk 刷新一次活动时钟(节流由调用方负责)。
401
+ * 可附带当前流式请求的实时累计 usage(input/output):写入 running 计数并广播 session-update,
402
+ * 使前端节点在流式过程中随 token 增长实时上移。finalize 时 onFinalized 会把最终值并入累计并清零 running。
403
+ */
404
+ heartbeat(sessionId, usage) {
405
+ if (!sessionId || sessionId === '-')
406
+ return;
407
+ const st = this.states.get(sessionId);
408
+ if (!st)
409
+ return;
410
+ const now = Date.now();
411
+ st.lastActivityAt = now;
412
+ if (st.streamFirstChunkAt === 0)
413
+ st.streamFirstChunkAt = now;
414
+ if (usage) {
415
+ st.runningInputTokens = usage.inputTokens || 0;
416
+ st.runningOutputTokens = usage.outputTokens || 0;
417
+ this.emitSession(st);
418
+ }
419
+ }
420
+ /** 请求结束:在途计数递减(流式请求同时递减在途流计数,思考请求递减思考计数)。不立即改 status,由 onFinalized / reevaluate / sweep 决定。 */
421
+ endRequest(sessionId, opts) {
320
422
  if (!sessionId || sessionId === '-')
321
423
  return;
322
424
  const st = this.states.get(sessionId);
323
425
  if (!st)
324
426
  return;
325
427
  st.inFlight = Math.max(0, st.inFlight - 1);
326
- // 不立即改 status,由 onFinalized 的重算决定
428
+ if (opts === null || opts === void 0 ? void 0 : opts.isStream)
429
+ st.streamingInFlight = Math.max(0, st.streamingInFlight - 1);
430
+ if (opts === null || opts === void 0 ? void 0 : opts.thinking)
431
+ st.thinkingInFlight = Math.max(0, st.thinkingInFlight - 1);
432
+ st.lastActivityAt = Date.now();
433
+ if (st.inFlight === 0) {
434
+ st.inFlightSince = 0;
435
+ st.streamingInFlight = 0;
436
+ st.thinkingInFlight = 0;
437
+ st.streamFirstChunkAt = 0;
438
+ // 在途已清空:实时累计让位给 onFinalized 的最终累计,避免双算
439
+ st.runningInputTokens = 0;
440
+ st.runningOutputTokens = 0;
441
+ }
442
+ }
443
+ /**
444
+ * 重算单个会话状态并广播(含通知判定)。供 onFinalized / 安全网 / REST 主动刷新复用。
445
+ * 返回是否有状态变化。
446
+ */
447
+ reevaluate(sessionId) {
448
+ if (!sessionId || sessionId === '-')
449
+ return false;
450
+ const st = this.states.get(sessionId);
451
+ if (!st)
452
+ return false;
453
+ const prevStatus = st.status;
454
+ const { status, reason } = this.inferStatus({
455
+ lastActivityAt: st.lastActivityAt,
456
+ lastStatusCode: st.lastStatusCode,
457
+ inFlight: st.inFlight,
458
+ streamingInFlight: st.streamingInFlight,
459
+ thinkingInFlight: st.thinkingInFlight,
460
+ streamFirstChunkAt: st.streamFirstChunkAt,
461
+ inFlightSince: st.inFlightSince,
462
+ lastTurnEnd: st.lastTurnEnd,
463
+ now: Date.now(),
464
+ });
465
+ if (status === prevStatus && reason === st.statusReason)
466
+ return false;
467
+ st.status = status;
468
+ st.statusReason = reason;
469
+ // 安全网路径(finalizeLog 未走 onFinalized 的泄漏场景):脱离 active 时补调度防抖通知
470
+ if (status !== 'active')
471
+ this.considerEndNotify(st, status === 'error');
472
+ this.emitSession(st);
473
+ return true;
327
474
  }
328
475
  // ============ 请求收尾:抽事件 + 重算状态 + 广播 ============
329
476
  onFinalized(ctx) {
477
+ var _a;
330
478
  if (!ctx.sessionId || ctx.sessionId === '-')
331
479
  return;
332
480
  const extractInput = {
@@ -374,8 +522,19 @@ class AgentMapService extends events_1.EventEmitter {
374
522
  lastRequestAt: ctx.timestamp,
375
523
  requestCount: 0,
376
524
  totalTokens: 0,
525
+ inputTokens: 0,
526
+ outputTokens: 0,
527
+ runningInputTokens: 0,
528
+ runningOutputTokens: 0,
377
529
  status: 'active',
378
530
  inFlight: 0,
531
+ lastActivityAt: ctx.timestamp,
532
+ streamingInFlight: 0,
533
+ thinkingInFlight: 0,
534
+ streamFirstChunkAt: 0,
535
+ inFlightSince: ctx.timestamp,
536
+ notifiedForTurn: false,
537
+ notifyTimer: null,
379
538
  };
380
539
  this.states.set(ctx.sessionId, st);
381
540
  }
@@ -392,6 +551,10 @@ class AgentMapService extends events_1.EventEmitter {
392
551
  st.requestCount += 1;
393
552
  if (ctx.tokensDelta)
394
553
  st.totalTokens += ctx.tokensDelta;
554
+ if (ctx.inputTokensDelta)
555
+ st.inputTokens += ctx.inputTokensDelta;
556
+ if (ctx.outputTokensDelta)
557
+ st.outputTokens += ctx.outputTokensDelta;
395
558
  if (ctx.model)
396
559
  st.lastModel = ctx.model;
397
560
  if (ctx.statusCode != null)
@@ -400,21 +563,31 @@ class AgentMapService extends events_1.EventEmitter {
400
563
  st.lastActivitySummary = summary;
401
564
  if (toolName)
402
565
  st.lastToolName = toolName;
403
- // 本轮响应是否表示「一轮结束」(精确信号,替代纯时间窗)
566
+ // 本轮响应是否表示「一轮结束」:
567
+ // - true = end_turn 等明确结束 → 任务完成,可弹通知
568
+ // - false = tool_use 等还要继续 → 客户端将本地执行工具后再次请求,此时静默、保持「进行中」
569
+ // - null = 无法判定 → 兜底按结束处理
404
570
  const turnEnd = (0, activity_extractor_1.detectTurnEnd)(ctx.agent, ctx.downstreamResponseBody, ctx.responseBody);
405
571
  st.lastTurnEnd = turnEnd;
406
- // 重算状态(onFinalized 时请求已结束,inFlight 会被 endRequest 减,但可能尚未调用)
407
- const prevStatus = st.status;
572
+ // 重算状态:请求已结束(endRequest 已把 inFlight 1)。inferStatus 会在「结束」情形下防抖保持 active。
408
573
  const { status, reason } = this.inferStatus({
409
- lastRequestAt: st.lastRequestAt,
574
+ lastActivityAt: st.lastActivityAt,
410
575
  lastStatusCode: st.lastStatusCode,
411
576
  inFlight: st.inFlight,
577
+ streamingInFlight: st.streamingInFlight,
578
+ thinkingInFlight: st.thinkingInFlight,
579
+ streamFirstChunkAt: st.streamFirstChunkAt,
580
+ inFlightSince: st.inFlightSince,
581
+ lastTurnEnd: turnEnd,
412
582
  now,
413
- turnEnd,
414
583
  });
415
584
  st.status = status;
416
585
  st.statusReason = reason;
417
- this.maybeNotifyTurnEnd(prevStatus, st);
586
+ // 任务结束检测:无在途且末响应非 tool_use(end_turn / 未知)→ 调度防抖通知。
587
+ // tool_use 不调度(客户端将本地执行工具后继续);防抖期内来新主请求会取消它。
588
+ if (st.inFlight === 0 && st.lastTurnEnd !== false) {
589
+ this.considerEndNotify(st, ((_a = st.lastStatusCode) !== null && _a !== void 0 ? _a : 0) >= 500);
590
+ }
418
591
  // 记录事件
419
592
  if (events.length > 0) {
420
593
  this.recordEvents(ctx.sessionId, events);
@@ -466,10 +639,61 @@ class AgentMapService extends events_1.EventEmitter {
466
639
  if (meta.title)
467
640
  st.title = meta.title;
468
641
  st.metaResolved = true;
642
+ // legacy 会话(修复前累积、拆分从未持久化):点开详情时按日志回填输入/输出拆分
643
+ this.backfillTokenSplit(st).catch(err => console.error('[AgentMap] backfillTokenSplit error:', err));
469
644
  }
470
645
  return { source, projectPath: meta.projectPath || (st === null || st === void 0 ? void 0 : st.projectPath), title: meta.title || (st === null || st === void 0 ? void 0 : st.title) };
471
646
  });
472
647
  }
648
+ /**
649
+ * 历史 token 输入/输出拆分回填。
650
+ * 仅对 legacy 会话生效:累计 token > 0 但拆分为 0(修复前从未持久化)。
651
+ * 从该会话的全部请求日志现算 usage.inputTokens / outputTokens 之和,写回 RuntimeState + DB(绝对值),
652
+ * 之后新请求照常在回填基线上累加。每个会话只回填一次(tokenSplitBackfilled)。
653
+ */
654
+ backfillTokenSplit(st) {
655
+ return __awaiter(this, void 0, void 0, function* () {
656
+ var _a, _b;
657
+ if (st.tokenSplitBackfilled)
658
+ return;
659
+ if (st.source !== 'global')
660
+ return;
661
+ // 仅 legacy 标记:累计有值但拆分为 0。新会话(拆分已随请求累加)不触发。
662
+ if (!(st.totalTokens > 0 && st.inputTokens === 0 && st.outputTokens === 0)) {
663
+ st.tokenSplitBackfilled = true;
664
+ return;
665
+ }
666
+ if (!((_a = this.db) === null || _a === void 0 ? void 0 : _a.getLogsBySessionId) || !((_b = this.db) === null || _b === void 0 ? void 0 : _b.updateSession)) {
667
+ st.tokenSplitBackfilled = true;
668
+ return;
669
+ }
670
+ st.tokenSplitBackfilled = true; // 先置位,防并发重复回填
671
+ try {
672
+ // 取全部日志(默认 limit=100 会漏算大 session),传一个大上限以覆盖完整历史
673
+ const logs = yield this.db.getLogsBySessionId(st.sessionId, Number.MAX_SAFE_INTEGER);
674
+ let inputTokens = 0;
675
+ let outputTokens = 0;
676
+ for (const log of logs) {
677
+ const u = log.usage;
678
+ if (!u)
679
+ continue;
680
+ inputTokens += u.inputTokens || u.promptTokens || 0;
681
+ outputTokens += u.outputTokens || u.completionTokens || 0;
682
+ }
683
+ if (inputTokens > 0 || outputTokens > 0) {
684
+ st.inputTokens = inputTokens;
685
+ st.outputTokens = outputTokens;
686
+ // 绝对写入 DB(不走 upsertSession 的累加语义),后续新请求在其上累加
687
+ yield this.db.updateSession(st.sessionId, { inputTokens, outputTokens });
688
+ this.emitSession(st);
689
+ }
690
+ }
691
+ catch (err) {
692
+ st.tokenSplitBackfilled = false; // 失败则允许下次重试
693
+ throw err;
694
+ }
695
+ });
696
+ }
473
697
  // ============ 任务结束 OS 通知 ============
474
698
  setNotifyEnabled(enabled) {
475
699
  this.notifyEnabled = !!enabled;
@@ -498,25 +722,60 @@ class AgentMapService extends events_1.EventEmitter {
498
722
  notifyTest() {
499
723
  (0, notifier_1.notify)({ title: '🔔 AICodeSwitch', body: '测试通知:通知功能可用' });
500
724
  }
501
- /** active → idle 迁移时,只要开关开启就弹 OS 通知(一轮工作结束)。不再看页面是否后台。 */
502
- maybeNotifyTurnEnd(prevStatus, st) {
725
+ /**
726
+ * 结束通知调度(基于「结束检测」,而非展示状态迁移):
727
+ * - onFinalized 检测到一轮结束(inFlight==0 且非 tool_use)时调用;
728
+ * - sweep / reevaluate 检测到停滞/泄漏脱离 active 时调用。
729
+ * 每轮只调度一次(notifiedForTurn 去重);499(用户取消)不调度。实际弹通知由 scheduleNotify 防抖。
730
+ * 展示状态的「防抖保持 active」由 inferStatus 负责,fireNotify 落实 idle,二者经 NOTIFY_DEBOUNCE_MS 对齐。
731
+ */
732
+ considerEndNotify(st, isError) {
503
733
  if (!this.notifyEnabled)
504
734
  return;
505
- if (prevStatus !== 'active' || st.status !== 'idle')
506
- return;
507
- // 499 = 用户主动放弃停止任务,属于预期行为,不弹系统通知
735
+ if (st.notifiedForTurn)
736
+ return; // 本轮已调度
508
737
  if (st.lastStatusCode === 499)
738
+ return; // 用户主动取消,不弹
739
+ st.notifiedForTurn = true;
740
+ this.scheduleNotify(st, isError);
741
+ }
742
+ /** 安排一条防抖结束通知(先取消已挂起的,再重新计时)。 */
743
+ scheduleNotify(st, isError) {
744
+ this.cancelNotifyTimer(st);
745
+ st.notifyTimer = setTimeout(() => {
746
+ st.notifyTimer = null;
747
+ this.fireNotify(st, isError);
748
+ }, NOTIFY_DEBOUNCE_MS);
749
+ // 不阻止进程退出
750
+ if (typeof st.notifyTimer.unref === 'function')
751
+ st.notifyTimer.unref();
752
+ }
753
+ /** 取消挂起的防抖通知(会话再次活跃时调用)。 */
754
+ cancelNotifyTimer(st) {
755
+ if (st.notifyTimer) {
756
+ clearTimeout(st.notifyTimer);
757
+ st.notifyTimer = null;
758
+ }
759
+ }
760
+ /** 真正弹出 OS 通知(防抖窗口结束、未被取消时调用)。过 60s 冷却兜底。同时把「防抖保持的 active」落实为 idle。 */
761
+ fireNotify(st, isError) {
762
+ // 防抖期间用户可能关掉了通知开关 → 不弹
763
+ if (!this.notifyEnabled)
509
764
  return;
510
- // 冷却去重:主轮结束后常伴有后台/计数/compact 等续发请求,各自 end_turn 会重复触发 active→idle,
511
- // 在冷却窗口内只弹一次,避免同一任务弹两条通知。
512
765
  const now = Date.now();
513
766
  if (st.lastNotifyAt && now - st.lastNotifyAt < NOTIFY_COOLDOWN_MS)
514
767
  return;
515
768
  st.lastNotifyAt = now;
769
+ // 把展示状态从「防抖保持的 active」落实为 idle,与通知同步;仅当确无在途请求(不覆盖后台请求进行中的 active)
770
+ if (st.inFlight === 0 && st.status === 'active') {
771
+ st.status = isError ? 'error' : 'idle';
772
+ st.statusReason = isError ? 'upstream error' : 'turn ended';
773
+ this.emitSession(st);
774
+ }
516
775
  const agentName = st.agent === 'codex' ? 'Codex' : 'Claude Code';
517
776
  (0, notifier_1.notify)({
518
- title: `✅ AICodeSwitch · ${agentName}`,
519
- body: st.title || st.lastActivitySummary || '任务已暂停,等待下一步',
777
+ title: `${isError ? '⚠️' : '✅'} AICodeSwitch · ${agentName}`,
778
+ body: st.title || st.lastActivitySummary || (isError ? '任务出现异常' : '任务已结束,等待下一步'),
520
779
  });
521
780
  }
522
781
  recordEvents(sessionId, events) {
@@ -531,35 +790,58 @@ class AgentMapService extends events_1.EventEmitter {
531
790
  this.globalEvents.splice(0, this.globalEvents.length - MAX_GLOBAL_EVENTS);
532
791
  }
533
792
  }
534
- // ============ 状态推断(纯函数,基于活跃度) ============
793
+ // ============ 状态推断(纯函数,基于「在途 + 真实活动时钟 + 轮次语义」) ============
535
794
  inferStatus(args) {
536
- // 在途请求 → active
537
- if (args.inFlight > 0)
538
- return { status: 'active', reason: 'in-flight' };
539
- // 末次失败 → error(即使时间较近也标 error,下一次成功会刷回 active)
795
+ // 末次上游 5xx error(即使时间较近也标 error,下一次成功会刷回 active
540
796
  if (args.lastStatusCode != null && args.lastStatusCode >= 500) {
541
- return { status: 'error', reason: `upstream ${args.lastStatusCode}` };
797
+ return { status: 'error', reason: `upstream ${args.lastStatusCode}`, notifyEligible: true };
798
+ }
799
+ // 在途请求:判定是否「真正存活」(区分同步 / SSE / 思考)。在途期间任何「停滞→idle」都可通知。
800
+ if (args.inFlight > 0) {
801
+ if (args.streamingInFlight > 0) {
802
+ if (!args.streamFirstChunkAt) {
803
+ // 首 Token 前:思考类模型 TTFT 可能较长,按同步超时 5min 等待
804
+ const since = args.inFlightSince || args.lastActivityAt;
805
+ if (args.now - since <= this.syncMaxMs)
806
+ return { status: 'active', reason: 'awaiting first token', notifyEligible: true };
807
+ return { status: 'idle', reason: 'stream stalled (no first token)', notifyEligible: true };
808
+ }
809
+ // 已开始产出:思考请求用更宽的 THINKING_SILENCE_MS,否则 SSE_SILENCE_MS(30s)
810
+ const limit = args.thinkingInFlight > 0 ? this.thinkingSilenceMs : this.sseSilenceMs;
811
+ const since = args.streamFirstChunkAt;
812
+ if (args.now - since <= limit) {
813
+ return { status: 'active', reason: args.thinkingInFlight > 0 ? 'thinking' : 'streaming', notifyEligible: true };
814
+ }
815
+ return { status: 'idle', reason: args.thinkingInFlight > 0 ? 'thinking timeout' : 'stream stalled', notifyEligible: true };
816
+ }
817
+ // 同步请求:5min 内视为仍在处理;超过则视作服务中断、强制结束
818
+ const since = args.inFlightSince || args.lastActivityAt;
819
+ if (args.now - since <= this.syncMaxMs)
820
+ return { status: 'active', reason: 'in-flight', notifyEligible: true };
821
+ return { status: 'idle', reason: 'sync timeout', notifyEligible: true };
542
822
  }
543
- const elapsed = args.now - args.lastRequestAt;
544
- // 499 = 客户端主动断开(用户放弃停止任务):视为「已取消」,立即停止脉冲进入 idle,过 idleWindow 再 completed
823
+ // 无在途请求:按「上一轮响应语义 + 距最近活动时长」判 idle / completed,并决定是否可通知
824
+ const elapsed = args.now - args.lastActivityAt;
825
+ // 499 = 客户端主动断开(用户放弃停止任务):视为「已取消」,立即进入 idle
545
826
  if (args.lastStatusCode === 499) {
546
827
  if (elapsed <= this.idleWindowMs)
547
- return { status: 'idle', reason: 'client cancelled' };
548
- return { status: 'completed', reason: 'cancelled earlier' };
828
+ return { status: 'idle', reason: 'client cancelled', notifyEligible: true };
829
+ return { status: 'completed', reason: 'cancelled earlier', notifyEligible: true };
549
830
  }
550
- // 精确信号:本轮响应明确「结束」→ 立即停止脉冲,进入 idle(超时再 completed)
551
- if (args.turnEnd === true) {
831
+ // tool_use:客户端正在本地执行工具,随后会再发请求 保持「进行中」,且**不可通知**(避免每次工具调用误弹)
832
+ if (args.lastTurnEnd === false) {
552
833
  if (elapsed <= this.idleWindowMs)
553
- return { status: 'idle', reason: 'turn ended' };
554
- return { status: 'completed', reason: 'inactive' };
555
- }
556
- // 仍将继续(tool_use)或信号未知 → 回退到活跃时间窗
557
- if (elapsed <= this.activeWindowMs) {
558
- return { status: 'active', reason: args.turnEnd === false ? 'tool loop' : 'recent request' };
834
+ return { status: 'active', reason: 'executing tool locally', notifyEligible: false };
835
+ return { status: 'idle', reason: 'tool exec abandoned', notifyEligible: false };
559
836
  }
837
+ // end_turn(true)或无法判定(null,兜底按结束处理):
838
+ // 先防抖保持 active 满 NOTIFY_DEBOUNCE_MS(避免请求边界处「闪一下空闲」),期满才落实 idle/completed。
839
+ // 防抖期内若发起新主请求,lastActivityAt 会被刷新 → 继续保持 active,不闪、不弹通知。
840
+ if (elapsed <= NOTIFY_DEBOUNCE_MS)
841
+ return { status: 'active', reason: 'turn ending (debounced)', notifyEligible: true };
560
842
  if (elapsed <= this.idleWindowMs)
561
- return { status: 'idle', reason: 'no recent activity' };
562
- return { status: 'completed', reason: 'inactive' };
843
+ return { status: 'idle', reason: 'turn ended', notifyEligible: true };
844
+ return { status: 'completed', reason: 'inactive', notifyEligible: true };
563
845
  }
564
846
  // ============ 定时清扫 ============
565
847
  sweep() {
@@ -568,17 +850,34 @@ class AgentMapService extends events_1.EventEmitter {
568
850
  this.states.forEach(st => {
569
851
  const prevStatus = st.status;
570
852
  const { status, reason } = this.inferStatus({
571
- lastRequestAt: st.lastRequestAt,
853
+ lastActivityAt: st.lastActivityAt,
572
854
  lastStatusCode: st.lastStatusCode,
573
855
  inFlight: st.inFlight,
856
+ streamingInFlight: st.streamingInFlight,
857
+ thinkingInFlight: st.thinkingInFlight,
858
+ streamFirstChunkAt: st.streamFirstChunkAt,
859
+ inFlightSince: st.inFlightSince,
860
+ lastTurnEnd: st.lastTurnEnd,
574
861
  now,
575
- turnEnd: st.lastTurnEnd,
576
862
  });
577
- if (status !== prevStatus) {
863
+ // 状态迁移,或在途请求已陈旧/泄漏(inFlight>0 却不再存活)→ 清零泄漏的计数器
864
+ const statusChanged = status !== prevStatus;
865
+ const leakedInFlight = st.inFlight > 0 && status !== 'active';
866
+ if (statusChanged || leakedInFlight) {
867
+ if (leakedInFlight) {
868
+ // 修复「永远卡在进行中」:endRequest 未配对到达(早退/异常)导致的在途泄漏,这里兜底清零
869
+ st.inFlight = 0;
870
+ st.streamingInFlight = 0;
871
+ st.thinkingInFlight = 0;
872
+ st.inFlightSince = 0;
873
+ st.streamFirstChunkAt = 0;
874
+ }
578
875
  st.status = status;
579
876
  st.statusReason = reason;
580
877
  changed = true;
581
- this.maybeNotifyTurnEnd(prevStatus, st);
878
+ // 停滞/泄漏/异常导致脱离 active → 调度防抖通知(正常结束已在 onFinalized 调度,notifiedForTurn 去重)
879
+ if (status !== 'active')
880
+ this.considerEndNotify(st, status === 'error');
582
881
  this.emitSession(st);
583
882
  }
584
883
  });
@@ -640,7 +939,10 @@ class AgentMapService extends events_1.EventEmitter {
640
939
  firstRequestAt: st.firstRequestAt,
641
940
  lastRequestAt: st.lastRequestAt,
642
941
  requestCount: st.requestCount,
643
- totalTokens: st.totalTokens,
942
+ // 展示值 = 已落盘累计 + 在途实时累计,使节点在流式过程中随 token 增长实时上移
943
+ totalTokens: st.totalTokens + st.runningInputTokens + st.runningOutputTokens,
944
+ inputTokens: st.inputTokens + st.runningInputTokens,
945
+ outputTokens: st.outputTokens + st.runningOutputTokens,
644
946
  lastToolName: st.lastToolName,
645
947
  lastActivitySummary: st.lastActivitySummary,
646
948
  lastStatusCode: st.lastStatusCode,
@@ -696,6 +998,8 @@ class AgentMapService extends events_1.EventEmitter {
696
998
  clearInterval(this.sweepTimer);
697
999
  this.sweepTimer = null;
698
1000
  }
1001
+ // 清理所有挂起的防抖通知定时器,避免销毁后仍触发
1002
+ this.states.forEach(st => this.cancelNotifyTimer(st));
699
1003
  this.states.clear();
700
1004
  this.sessionEvents.clear();
701
1005
  this.globalEvents = [];
@@ -276,8 +276,16 @@ function buildTargetBody(options) {
276
276
  if (result.messages) {
277
277
  result.messages = (0, mapper_js_2.fixThinkingHistory)(result.messages, 'completions');
278
278
  }
279
- // 剥离 stream_options(reasoning_content 提供商通常不支持)
280
- delete result.stream_options;
279
+ // stream_options 剥离策略由 providerConfig.streamOptions 决定:
280
+ // 'supported' → 保留(DeepSeek 等支持 include_usage,能拿到真实 usage)
281
+ // 'unsupported' → 剥离(已知会报错的供应商)
282
+ // 'auto'/缺省 → 维持历史行为:reasoning_content 剥离
283
+ const streamOpt = providerConfig.streamOptions || 'auto';
284
+ const shouldStripStreamOptions = streamOpt === 'unsupported' ||
285
+ (streamOpt === 'auto' && isReasoningContentCompletion);
286
+ if (shouldStripStreamOptions) {
287
+ delete result.stream_options;
288
+ }
281
289
  }
282
290
  // 注入 thinking 参数(如 thinking: { type: 'enabled' })和 effort 参数
283
291
  if (providerConfig.supportsThinking || providerConfig.supportsEffort) {
@@ -6,17 +6,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.DEFAULT_CONFIG = void 0;
7
7
  exports.getReasoningConfig = getReasoningConfig;
8
8
  exports.applyReasoningConfig = applyReasoningConfig;
9
+ // streamOptions 语义(决定流式请求是否保留 stream_options.include_usage):
10
+ // 'supported' → 已知支持 include_usage 的供应商,保留 → 流式可拿到真实 usage
11
+ // 'unsupported' → 已知会对 stream_options 报错的供应商,剥离
12
+ // 'auto' → 未明确,由 conversions/index.ts 按 outputFormat 兜底(reasoning_content 剥离,其余保留)
9
13
  const PROVIDER_CONFIGS = [
10
- { patterns: ['deepseek'], config: { supportsThinking: true, supportsEffort: true, thinkingParam: 'thinking', effortParam: 'reasoning_effort', effortValueMode: 'deepseek', outputFormat: 'reasoning_content' } },
11
- { patterns: ['moonshot', 'kimi'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
12
- { patterns: ['qwen', 'dashscope'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'enable_thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
13
- { patterns: ['zhipu', 'glm', 'bigmodel'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
14
+ { patterns: ['deepseek'], config: { supportsThinking: true, supportsEffort: true, thinkingParam: 'thinking', effortParam: 'reasoning_effort', effortValueMode: 'deepseek', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
15
+ { patterns: ['moonshot', 'kimi'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
16
+ { patterns: ['qwen', 'dashscope'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'enable_thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
17
+ { patterns: ['zhipu', 'glm', 'bigmodel'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
14
18
  { patterns: ['minimax'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'reasoning_split', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_details' } },
15
- { patterns: ['mimo', 'xiaomimimo'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
19
+ { patterns: ['mimo', 'xiaomimimo'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'auto' } },
16
20
  { patterns: ['openrouter'], config: { supportsThinking: false, supportsEffort: true, thinkingParam: 'none', effortParam: 'reasoning.effort', effortValueMode: 'openrouter', outputFormat: 'reasoning' } },
17
- { patterns: ['siliconflow'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'enable_thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
21
+ { patterns: ['siliconflow'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'enable_thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
18
22
  { patterns: ['stepfun', 'step'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'none', effortParam: 'reasoning_effort', effortValueMode: 'low_high', outputFormat: 'reasoning' } },
19
- { patterns: ['agnes'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'chat_template_kwargs', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
23
+ { patterns: ['agnes'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'chat_template_kwargs', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'auto' } },
20
24
  ];
21
25
  const DEFAULT_CONFIG = {
22
26
  supportsThinking: false,
@@ -25,6 +29,7 @@ const DEFAULT_CONFIG = {
25
29
  effortParam: 'none',
26
30
  effortValueMode: 'passthrough',
27
31
  outputFormat: 'reasoning_content',
32
+ streamOptions: 'auto',
28
33
  };
29
34
  exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
30
35
  /** Detect reasoning config for a given provider */