dsh-client-auto-continue 0.7.3 → 0.7.5
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.
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/lib/client.js +172 -65
- package/lib/client.js.map +3 -3
- package/lib/types/client/engine.d.ts +7 -2
- package/package.json +1 -1
- package/src/client/engine.ts +226 -72
|
@@ -229,8 +229,13 @@ export declare class AutoContinueRunner {
|
|
|
229
229
|
private buildContinueText;
|
|
230
230
|
/** 上一步工具调用的护栏状态(实时路径, 由 mux 帧维护)。 */
|
|
231
231
|
private currentGuard;
|
|
232
|
-
/**
|
|
233
|
-
|
|
232
|
+
/**
|
|
233
|
+
* 宿主权威兜底: 历史里最后一条事件是否就是同一文本的 user 消息。
|
|
234
|
+
* 是 = 它还在排队未被处理, 不应再叠加发送; 否(回合结束等其他事件)= 放行。
|
|
235
|
+
* 查询失败时返回 false(放行, 本地防线仍在)。
|
|
236
|
+
*/
|
|
237
|
+
private hostHasPendingSameText;
|
|
238
|
+
/** 会话标题缓存(来自 session.list 投影, {sessionTitle} 占位符用)。 */ private readonly titles;
|
|
234
239
|
/** 查一次 session.list, 顺带缓存该会话的标题。 */
|
|
235
240
|
private fetchSessionInfo;
|
|
236
241
|
private runningViaList;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-client-auto-continue",
|
|
3
3
|
"description": "DSH Web UI plugin: automatically sends \"继续\" (continue) when a request is interrupted by network errors or other non-human causes",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/client/engine.ts
CHANGED
|
@@ -390,38 +390,118 @@ function clientTimeZone(): string | undefined {
|
|
|
390
390
|
const lockPrefix = 'dsh-auto-continue:';
|
|
391
391
|
const lockKey = (sessionId: SessionId) => `${lockPrefix}lock:${sessionId}`;
|
|
392
392
|
const stampKey = (sessionId: SessionId) => `${lockPrefix}last:${sessionId}`;
|
|
393
|
+
const countKey = (sessionId: SessionId) => `${lockPrefix}count:${sessionId}`;
|
|
393
394
|
|
|
394
|
-
/**
|
|
395
|
-
|
|
395
|
+
/**
|
|
396
|
+
* 上次自动发送的完整记录(跨标签页): 时间戳 + 文本。
|
|
397
|
+
* 回显识别用它而不是单 runner 内存, 所以任何标签页发出的消息都能被所有标签页认出。
|
|
398
|
+
*/
|
|
399
|
+
function readLastSent(sessionId: SessionId): { at: number; text: string } {
|
|
396
400
|
try {
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
401
|
+
const raw = localStorage.getItem(stampKey(sessionId));
|
|
402
|
+
if (raw === null) return { at: 0, text: '' };
|
|
403
|
+
const parsed = JSON.parse(raw);
|
|
404
|
+
if (typeof parsed === 'object' && parsed !== null && typeof parsed.text === 'string') {
|
|
405
|
+
return { at: Number(parsed.at) || 0, text: parsed.text };
|
|
406
|
+
}
|
|
407
|
+
return { at: Number(raw) || 0, text: '' }; // 兼容旧格式(纯时间戳)
|
|
400
408
|
} catch {
|
|
401
|
-
return
|
|
409
|
+
return { at: 0, text: '' };
|
|
402
410
|
}
|
|
403
411
|
}
|
|
404
412
|
|
|
405
|
-
|
|
413
|
+
/** 读「上次自动发送」时间戳(跨标签页冷却)。 */
|
|
414
|
+
function readLastSend(sessionId: SessionId): number {
|
|
415
|
+
return readLastSent(sessionId).at;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function writeLastSend(sessionId: SessionId, at: number, text: string): void {
|
|
406
419
|
try {
|
|
407
|
-
localStorage.
|
|
420
|
+
localStorage.setItem(stampKey(sessionId), JSON.stringify({ at, text }));
|
|
408
421
|
} catch {
|
|
409
422
|
/* ignore */
|
|
410
423
|
}
|
|
411
424
|
}
|
|
412
425
|
|
|
413
|
-
|
|
414
|
-
|
|
426
|
+
// ---------- 跨标签页发送计数(硬上限, 不依赖回显识别) ----------
|
|
427
|
+
|
|
428
|
+
/** 发送计数窗口: 超过该时长无新发送, 计数自动失效。 */
|
|
429
|
+
const SEND_COUNT_WINDOW_MS = 10 * 60 * 1000;
|
|
430
|
+
|
|
431
|
+
interface SendCount {
|
|
432
|
+
at: number;
|
|
433
|
+
count: number;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function readSendCount(sessionId: SessionId): SendCount {
|
|
415
437
|
try {
|
|
416
|
-
|
|
438
|
+
const raw = localStorage.getItem(countKey(sessionId));
|
|
439
|
+
if (raw === null) return { at: 0, count: 0 };
|
|
440
|
+
const parsed = JSON.parse(raw);
|
|
441
|
+
if (typeof parsed === 'object' && parsed !== null && typeof parsed.count === 'number') {
|
|
442
|
+
const at = Number(parsed.at) || 0;
|
|
443
|
+
if (Date.now() - at > SEND_COUNT_WINDOW_MS) return { at: 0, count: 0 }; // 窗口过期
|
|
444
|
+
return { at, count: parsed.count };
|
|
445
|
+
}
|
|
417
446
|
} catch {
|
|
418
|
-
|
|
447
|
+
/* ignore */
|
|
419
448
|
}
|
|
449
|
+
return { at: 0, count: 0 };
|
|
420
450
|
}
|
|
421
451
|
|
|
422
|
-
function
|
|
452
|
+
function bumpSendCount(sessionId: SessionId): void {
|
|
423
453
|
try {
|
|
424
|
-
|
|
454
|
+
const current = readSendCount(sessionId);
|
|
455
|
+
localStorage.setItem(
|
|
456
|
+
countKey(sessionId),
|
|
457
|
+
JSON.stringify({ at: Date.now(), count: current.count + 1 }),
|
|
458
|
+
);
|
|
459
|
+
} catch {
|
|
460
|
+
/* ignore */
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 用户介入或成功回合后清零发送计数(跨标签页共享)。 */
|
|
465
|
+
function clearSendCount(sessionId: SessionId): void {
|
|
466
|
+
try {
|
|
467
|
+
localStorage.removeItem(countKey(sessionId));
|
|
468
|
+
} catch {
|
|
469
|
+
/* ignore */
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Web Locks: 跨标签页原子发送锁; 不可用(旧浏览器/测试环境)时回退到互斥戳。 */
|
|
474
|
+
async function withSendLock(sessionId: SessionId, body: () => Promise<void>): Promise<void> {
|
|
475
|
+
const nav = (globalThis as { navigator?: unknown }).navigator as
|
|
476
|
+
| { locks?: { request: (name: string, cb: () => Promise<void>) => Promise<void> } }
|
|
477
|
+
| undefined;
|
|
478
|
+
if (nav?.locks !== undefined) {
|
|
479
|
+
await nav.locks.request(`dsh-auto-continue:send:${sessionId}`, body);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// 回退: 尽力互斥(单标签页下可靠; 旧浏览器无 Web Locks)
|
|
483
|
+
if (!claimSend(sessionId)) return;
|
|
484
|
+
try {
|
|
485
|
+
await body();
|
|
486
|
+
} finally {
|
|
487
|
+
releaseSend(sessionId);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** 尝试独占本次发送: 两个标签页同时触发时只有一个成功(Web Locks 的回退方案)。 */
|
|
492
|
+
function claimSend(sessionId: SessionId): boolean {
|
|
493
|
+
try {
|
|
494
|
+
const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
495
|
+
localStorage.setItem(lockKey(sessionId), token);
|
|
496
|
+
return localStorage.getItem(lockKey(sessionId)) === token;
|
|
497
|
+
} catch {
|
|
498
|
+
return true; // 存储不可用(隐私模式等)时放行
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function releaseSend(sessionId: SessionId): void {
|
|
503
|
+
try {
|
|
504
|
+
localStorage.removeItem(lockKey(sessionId));
|
|
425
505
|
} catch {
|
|
426
506
|
/* ignore */
|
|
427
507
|
}
|
|
@@ -628,6 +708,8 @@ interface SessionState {
|
|
|
628
708
|
| undefined;
|
|
629
709
|
/** 本回合已触发过 loop guard(防重复打断)。 */
|
|
630
710
|
loopFired: boolean;
|
|
711
|
+
/** loop 重启的延迟定时器(冷却结束后再 schedule)。 */
|
|
712
|
+
loopRetryTimer: number | undefined;
|
|
631
713
|
/** 我们主动 cancel 过本回合(区分用户停止)。 */
|
|
632
714
|
loopCancelled: boolean;
|
|
633
715
|
}
|
|
@@ -654,23 +736,32 @@ const freshState = (): SessionState => ({
|
|
|
654
736
|
toolRun: undefined,
|
|
655
737
|
loopFired: false,
|
|
656
738
|
loopCancelled: false,
|
|
739
|
+
loopRetryTimer: undefined,
|
|
657
740
|
});
|
|
658
741
|
|
|
659
742
|
/** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
|
|
660
743
|
const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
|
|
661
744
|
|
|
662
|
-
/**
|
|
663
|
-
|
|
745
|
+
/** 回显识别窗口: 排队消息可能几分钟后才被模型处理到, 窗口必须远大于排队延迟。 */
|
|
746
|
+
const ECHO_WINDOW_MS = 10 * 60 * 1000;
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* 判定一条 user/message 是否是我们自己自动发送的回显。
|
|
750
|
+
* 用 localStorage 里的上次发送记录(跨标签页): 任何标签页发出的消息,
|
|
751
|
+
* 所有标签页都能认出——排队回显不会误判为「用户介入」而清零上限。
|
|
752
|
+
*/
|
|
753
|
+
function isOurEcho(state: SessionState, sessionId: SessionId, event: SessionEvent): boolean {
|
|
664
754
|
if (event.type !== 'user/message') return false;
|
|
665
755
|
const message = event.data;
|
|
666
756
|
if (message.source.kind !== 'user') return false;
|
|
667
|
-
|
|
668
|
-
if (
|
|
757
|
+
const last = readLastSent(sessionId);
|
|
758
|
+
if (last.at === 0 || last.text === '') return false;
|
|
759
|
+
if (Date.now() - last.at > ECHO_WINDOW_MS) return false;
|
|
669
760
|
const text = message.content
|
|
670
761
|
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
671
762
|
.map((part) => part.text)
|
|
672
763
|
.join('');
|
|
673
|
-
return text ===
|
|
764
|
+
return text === last.text;
|
|
674
765
|
}
|
|
675
766
|
|
|
676
767
|
/** SSE 帧外壳: `{ rpcId, payload }`。 */
|
|
@@ -754,6 +845,7 @@ export class AutoContinueRunner {
|
|
|
754
845
|
this.hostAbort.abort();
|
|
755
846
|
for (const state of this.states.values()) {
|
|
756
847
|
if (state.pendingTimer !== undefined) clearTimeout(state.pendingTimer);
|
|
848
|
+
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
757
849
|
}
|
|
758
850
|
this.states.clear();
|
|
759
851
|
}
|
|
@@ -956,6 +1048,10 @@ export class AutoContinueRunner {
|
|
|
956
1048
|
state.toolRun = undefined;
|
|
957
1049
|
state.loopFired = false;
|
|
958
1050
|
state.loopCancelled = false;
|
|
1051
|
+
if (state.loopRetryTimer !== undefined) {
|
|
1052
|
+
clearTimeout(state.loopRetryTimer);
|
|
1053
|
+
state.loopRetryTimer = undefined;
|
|
1054
|
+
}
|
|
959
1055
|
this.cancelPending(sessionId, '宿主自行开启新回合');
|
|
960
1056
|
break;
|
|
961
1057
|
case 'turn/end': {
|
|
@@ -966,26 +1062,39 @@ export class AutoContinueRunner {
|
|
|
966
1062
|
// 成功回合: 恢复健康状态, 并确认上一次自动发送的效果
|
|
967
1063
|
state.consecutive = 0;
|
|
968
1064
|
state.lastFailure = undefined;
|
|
1065
|
+
clearSendCount(sessionId);
|
|
969
1066
|
this.noteRecovery(sessionId, 'completed');
|
|
970
1067
|
} else if (reason.kind === 'aborted') {
|
|
971
1068
|
if (state.loopCancelled) {
|
|
972
1069
|
// 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
|
|
973
|
-
//
|
|
1070
|
+
// 不清 consecutive / lastAttemptAt: 冷却与连续上限在 loop 路径同样生效,
|
|
1071
|
+
// 防止无限打断重发(issue #13); 打断本身受冷却约束, 重启也要等冷却。
|
|
974
1072
|
state.loopCancelled = false;
|
|
975
1073
|
state.loopFired = false;
|
|
976
|
-
state.consecutive = 0;
|
|
977
1074
|
state.pendingRecoveryAt = 0;
|
|
978
1075
|
state.shortRun = 0;
|
|
979
1076
|
state.lastShortAt = 0;
|
|
980
1077
|
state.lastAssistantText = '';
|
|
981
1078
|
state.sameTextRun = 0;
|
|
982
1079
|
state.toolRun = undefined;
|
|
983
|
-
|
|
984
|
-
this.
|
|
1080
|
+
// 重启受冷却约束(防紧密打断循环): 等剩余冷却结束后再调度
|
|
1081
|
+
const cooldown = this.cooldownFor(state);
|
|
1082
|
+
const remaining = cooldown - (Date.now() - state.lastAttemptAt);
|
|
1083
|
+
if (remaining > 0) {
|
|
1084
|
+
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
1085
|
+
state.loopRetryTimer = setTimeout(() => {
|
|
1086
|
+
state.loopRetryTimer = undefined;
|
|
1087
|
+
this.schedule(sessionId, 'loop:aborted');
|
|
1088
|
+
}, remaining);
|
|
1089
|
+
this.log(`loop 重启延迟 ${remaining}ms(冷却期) ${sessionId}`);
|
|
1090
|
+
} else {
|
|
1091
|
+
this.schedule(sessionId, 'loop:aborted');
|
|
1092
|
+
}
|
|
985
1093
|
} else {
|
|
986
1094
|
// 用户主动停止: 不自动继续, 视为用户介入
|
|
987
1095
|
state.consecutive = 0;
|
|
988
1096
|
state.pendingRecoveryAt = 0;
|
|
1097
|
+
clearSendCount(sessionId);
|
|
989
1098
|
}
|
|
990
1099
|
} else if (reason.kind === 'blocked') {
|
|
991
1100
|
// 策略拒绝: 不自动继续
|
|
@@ -1015,10 +1124,11 @@ export class AutoContinueRunner {
|
|
|
1015
1124
|
break;
|
|
1016
1125
|
}
|
|
1017
1126
|
case 'user/message':
|
|
1018
|
-
if (isOurEcho(state, event)) break; // 我们自己的回显
|
|
1127
|
+
if (isOurEcho(state, sessionId, event)) break; // 我们自己的回显(跨标签页识别)
|
|
1019
1128
|
if (event.data.source.kind === 'user') {
|
|
1020
|
-
//
|
|
1129
|
+
// 用户手动介入: 清零上限与跨标签页发送计数
|
|
1021
1130
|
state.consecutive = 0;
|
|
1131
|
+
clearSendCount(sessionId);
|
|
1022
1132
|
this.cancelPending(sessionId, '用户手动发送消息');
|
|
1023
1133
|
}
|
|
1024
1134
|
break;
|
|
@@ -1212,13 +1322,10 @@ export class AutoContinueRunner {
|
|
|
1212
1322
|
this.log(`跳过 ${sessionId}: 已有排队消息`);
|
|
1213
1323
|
return;
|
|
1214
1324
|
}
|
|
1215
|
-
//
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
}
|
|
1220
|
-
if (!claimSend(sessionId)) {
|
|
1221
|
-
this.log(`跳过 ${sessionId}: 其他标签页正在发送`);
|
|
1325
|
+
// 跨标签页持久化发送计数: 窗口内达到 maxConsecutive 即硬抑制,
|
|
1326
|
+
// 不依赖回显识别与单 runner 内存(issue #13 的多标签页刷屏防线)。
|
|
1327
|
+
if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
|
|
1328
|
+
this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
|
|
1222
1329
|
return;
|
|
1223
1330
|
}
|
|
1224
1331
|
// 模板填充: continueText 可含 {code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed} 占位符
|
|
@@ -1237,51 +1344,76 @@ export class AutoContinueRunner {
|
|
|
1237
1344
|
}
|
|
1238
1345
|
const text = this.buildContinueText(config, state, template, sessionTitle);
|
|
1239
1346
|
const zone = clientTimeZone();
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1347
|
+
// 跨标签页原子发送锁(Web Locks; 回退到互斥戳):
|
|
1348
|
+
// 冷却检查、发送、计数、时间戳全部在锁内, 两个标签页不会同时放行。
|
|
1349
|
+
await withSendLock(sessionId, async () => {
|
|
1350
|
+
if (this.disposed) return;
|
|
1351
|
+
if (state.queued > 0) {
|
|
1352
|
+
this.log(`跳过 ${sessionId}: 已有排队消息`);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
// 跨标签页冷却(自适应退避); 通知按钮的强制续跑不受冷却约束
|
|
1356
|
+
if (!force && Date.now() - readLastSend(sessionId) < this.cooldownFor(state)) {
|
|
1357
|
+
this.log(`跳过 ${sessionId}: 其他标签页刚发送过`);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (!force && readSendCount(sessionId).count >= config.maxConsecutive) {
|
|
1361
|
+
this.log(`跳过 ${sessionId}: 发送计数已达上限 ${config.maxConsecutive}, 等待用户介入或成功回合`);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
// 宿主权威兜底: 历史里最后一条事件若正是同一文本的 user 消息, 说明它还在
|
|
1365
|
+
// 排队未被处理——不再叠加发送(issue #13 的 13 条排队场景)。
|
|
1366
|
+
// 若最后一条是回合结束等其他事件, 说明之前的同文本消息已被处理, 正常放行
|
|
1367
|
+
// (连续续跑不被误挡)。查询失败时放行(本地防线仍在)。
|
|
1368
|
+
if (!force && (await this.hostHasPendingSameText(sessionId, text))) {
|
|
1369
|
+
this.log(`跳过 ${sessionId}: 宿主队列里已有相同文本消息在排队`);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
|
|
1373
|
+
try {
|
|
1374
|
+
const response = await this.api.sessions.prompt({
|
|
1375
|
+
sessionId,
|
|
1376
|
+
mode: 'queue',
|
|
1377
|
+
content: [{ type: 'text', text }],
|
|
1378
|
+
...(zone === undefined ? {} : { clientTimeZone: zone }),
|
|
1379
|
+
});
|
|
1380
|
+
if (response.result.ok) {
|
|
1381
|
+
const now = Date.now();
|
|
1382
|
+
state.consecutive += 1;
|
|
1383
|
+
state.lastAutoAt = now;
|
|
1384
|
+
state.lastSentText = text;
|
|
1385
|
+
state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
|
|
1386
|
+
writeLastSend(sessionId, now, text); // 记录文本: 跨标签页回显识别
|
|
1387
|
+
bumpSendCount(sessionId); // 跨标签页持久化计数(硬上限)
|
|
1388
|
+
bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
|
|
1389
|
+
this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
|
|
1267
1390
|
if (config.notify) {
|
|
1268
1391
|
notify(
|
|
1269
|
-
'dsh-auto-continue:
|
|
1270
|
-
`${sessionId}:
|
|
1392
|
+
'dsh-auto-continue: 已自动继续',
|
|
1393
|
+
`${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
|
|
1271
1394
|
this.notifyOptions(sessionId),
|
|
1272
1395
|
);
|
|
1273
1396
|
}
|
|
1397
|
+
if (state.consecutive >= config.maxConsecutive) {
|
|
1398
|
+
bumpStat({ gaveUp: 1 });
|
|
1399
|
+
this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
|
|
1400
|
+
if (config.notify) {
|
|
1401
|
+
notify(
|
|
1402
|
+
'dsh-auto-continue: 已停止自动继续',
|
|
1403
|
+
`${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
|
|
1404
|
+
this.notifyOptions(sessionId),
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
} else {
|
|
1409
|
+
this.log(
|
|
1410
|
+
`发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`,
|
|
1411
|
+
);
|
|
1274
1412
|
}
|
|
1275
|
-
}
|
|
1276
|
-
this.log(
|
|
1277
|
-
`发送失败 ${sessionId}: ${response.result.error.code} ${response.result.error.message}`,
|
|
1278
|
-
);
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1279
1415
|
}
|
|
1280
|
-
}
|
|
1281
|
-
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1282
|
-
} finally {
|
|
1283
|
-
releaseSend(sessionId);
|
|
1284
|
-
}
|
|
1416
|
+
});
|
|
1285
1417
|
}
|
|
1286
1418
|
|
|
1287
1419
|
/**
|
|
@@ -1329,8 +1461,30 @@ export class AutoContinueRunner {
|
|
|
1329
1461
|
return { kind: 'failed', tool: state.lastTool };
|
|
1330
1462
|
}
|
|
1331
1463
|
|
|
1332
|
-
/**
|
|
1333
|
-
|
|
1464
|
+
/**
|
|
1465
|
+
* 宿主权威兜底: 历史里最后一条事件是否就是同一文本的 user 消息。
|
|
1466
|
+
* 是 = 它还在排队未被处理, 不应再叠加发送; 否(回合结束等其他事件)= 放行。
|
|
1467
|
+
* 查询失败时返回 false(放行, 本地防线仍在)。
|
|
1468
|
+
*/
|
|
1469
|
+
private async hostHasPendingSameText(sessionId: SessionId, text: string): Promise<boolean> {
|
|
1470
|
+
try {
|
|
1471
|
+
const response = await this.api.sessions.history({ sessionId, maxMessages: 10 });
|
|
1472
|
+
if (!response.result.ok) return false;
|
|
1473
|
+
const events = response.result.value.events;
|
|
1474
|
+
const last = events[events.length - 1]?.event;
|
|
1475
|
+
if (last === undefined || last.type !== 'user/message') return false;
|
|
1476
|
+
if (last.data.source?.kind !== 'user') return false;
|
|
1477
|
+
const lastText = (last.data.content ?? [])
|
|
1478
|
+
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
1479
|
+
.map((part) => part.text)
|
|
1480
|
+
.join('');
|
|
1481
|
+
return lastText === text;
|
|
1482
|
+
} catch {
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/** 会话标题缓存(来自 session.list 投影, {sessionTitle} 占位符用)。 */ private readonly titles = new Map<SessionId, string>();
|
|
1334
1488
|
|
|
1335
1489
|
/** 查一次 session.list, 顺带缓存该会话的标题。 */
|
|
1336
1490
|
private async fetchSessionInfo(
|