dsh-client-auto-continue 0.11.3 → 0.11.4

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.
@@ -297,6 +297,53 @@ export function fillTemplate(template: string, ctx: TemplateContext): string {
297
297
  /** 工具结果摘要的最大长度(护栏模板 {result} 用)。 */
298
298
  const TOOL_RESULT_CAP = 160;
299
299
 
300
+ /**
301
+ * 给 JSON 值生成定长且键顺序无关的稳定指纹。
302
+ *
303
+ * 这里不保存可能很大的工具输出原文;每个字符都会进入两个独立的
304
+ * 32-bit 累加器,再附上字符数,供 loop guard 比较完整的模型可见结果。
305
+ */
306
+ function stableFingerprint(value: unknown): string {
307
+ let first = 0x811c9dc5;
308
+ let second = 0x9e3779b9;
309
+ let length = 0;
310
+ const feed = (text: string): void => {
311
+ length += text.length;
312
+ for (let i = 0; i < text.length; i += 1) {
313
+ const code = text.charCodeAt(i);
314
+ first = Math.imul(first ^ code, 0x01000193) >>> 0;
315
+ second = Math.imul(second ^ code, 0x85ebca6b) >>> 0;
316
+ second = (second ^ (second >>> 13)) >>> 0;
317
+ }
318
+ };
319
+ const walk = (part: unknown): void => {
320
+ if (part === null) {
321
+ feed('null');
322
+ } else if (Array.isArray(part)) {
323
+ feed('[');
324
+ for (const item of part) {
325
+ walk(item);
326
+ feed(',');
327
+ }
328
+ feed(']');
329
+ } else if (typeof part === 'object') {
330
+ feed('{');
331
+ const record = part as Record<string, unknown>;
332
+ for (const key of Object.keys(record).sort()) {
333
+ feed(JSON.stringify(key));
334
+ feed(':');
335
+ walk(record[key]);
336
+ feed(',');
337
+ }
338
+ feed('}');
339
+ } else {
340
+ feed(`${typeof part}:${JSON.stringify(part) ?? String(part)}`);
341
+ }
342
+ };
343
+ walk(value);
344
+ return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}:${length}`;
345
+ }
346
+
300
347
  /** 从任意内容块里递归收集文本(结果为模型可见的工具输出)。 */
301
348
  function extractText(blocks: unknown, cap: number): string {
302
349
  let out = '';
@@ -324,15 +371,363 @@ export interface ToolResultFacts {
324
371
  ok: boolean;
325
372
  /** 工具输出的文本摘要(截断)。 */
326
373
  excerpt: string;
374
+ /** 完整模型可见内容 + 错误状态的定长稳定指纹(loop guard 比较用)。 */
375
+ identity: string;
327
376
  }
328
377
 
329
- /** tool/result 事件载荷提取成功与否与文本摘要。 */
330
- export function toolResultFacts(data: {
378
+ interface ToolResultData {
379
+ turn?: unknown;
380
+ step?: unknown;
331
381
  error?: { name?: string; code?: string };
332
- message?: { content?: Array<{ type?: string; content?: unknown; isError?: boolean }> };
333
- }): ToolResultFacts {
334
- const failed = data.error !== undefined || data.message?.content?.[0]?.isError === true;
335
- return { ok: !failed, excerpt: extractText(data.message?.content?.[0]?.content, TOOL_RESULT_CAP) };
382
+ message?: {
383
+ source?: { kind?: string; callId?: unknown };
384
+ content?: Array<{ type?: string; toolCallId?: unknown; content?: unknown; isError?: boolean }>;
385
+ };
386
+ }
387
+
388
+ function toolCorrelationKey(
389
+ data: { turn?: unknown; step?: unknown },
390
+ callId: string | undefined,
391
+ ): string | undefined {
392
+ if (callId === undefined || typeof data.turn !== 'number' || typeof data.step !== 'number') {
393
+ return undefined;
394
+ }
395
+ return JSON.stringify([data.turn, data.step, callId]);
396
+ }
397
+
398
+ function resultBlock(data: ToolResultData) {
399
+ return data.message?.content?.find((part) => part.type === 'tool-result');
400
+ }
401
+
402
+ /**
403
+ * 取工具结果的关联 id。新版 DSH 的权威位置是 message.source.callId,
404
+ * 同时接受模型可见 block 上的 toolCallId;两者冲突时宁可忽略,不猜测配对。
405
+ */
406
+ export function toolResultCallId(data: ToolResultData): string | undefined {
407
+ if (resultBlock(data) === undefined) return undefined;
408
+ const sourceId = data.message?.source?.kind === 'tool' ? data.message.source.callId : undefined;
409
+ const blockId = resultBlock(data)?.toolCallId;
410
+ const source = typeof sourceId === 'string' && sourceId !== '' ? sourceId : undefined;
411
+ const block = typeof blockId === 'string' && blockId !== '' ? blockId : undefined;
412
+ if (source !== undefined && block !== undefined && source !== block) return undefined;
413
+ return source ?? block;
414
+ }
415
+
416
+ /** 从 tool/result 事件载荷提取成功与否与文本摘要。 */
417
+ export function toolResultFacts(data: ToolResultData): ToolResultFacts {
418
+ const result = resultBlock(data);
419
+ const failed = data.error !== undefined || result?.isError === true;
420
+ return {
421
+ ok: !failed,
422
+ excerpt: extractText(result?.content, TOOL_RESULT_CAP),
423
+ identity: stableFingerprint({ content: result?.content ?? [], isError: failed }),
424
+ };
425
+ }
426
+
427
+ /** loop guard 在后续 step 边界确认的连续重复信号。 */
428
+ export interface ToolRepeatSignal {
429
+ tool: string;
430
+ count: number;
431
+ }
432
+
433
+ export type ToolGuardState =
434
+ | { kind: 'none' }
435
+ | { kind: 'pending'; tool: string }
436
+ | { kind: 'done'; tool: string; result: string }
437
+ | { kind: 'failed'; tool: string };
438
+
439
+ interface TrackedToolCall {
440
+ id: string | undefined;
441
+ name: string;
442
+ key: string;
443
+ result: ToolResultFacts | undefined;
444
+ resultSeq: number | undefined;
445
+ }
446
+
447
+ const MAX_PENDING_TOOL_CALLS = 64;
448
+ const MAX_SEEN_TOOL_CALL_IDS = 256;
449
+
450
+ /**
451
+ * 每个会话的工具调用关联器。
452
+ *
453
+ * 集中封装事件关联、step 边界确认、护栏读取与重置。内部按 callId 配对,
454
+ * 乱序结果先缓存、再按调用顺序推进 loop 计数。队列和去重 id 都有硬上限;
455
+ * 超限或载荷无法关联时会打断重复计数,宁可漏报也不误杀健康回合。
456
+ */
457
+ export class ToolInvocationTracker {
458
+ private readonly pendingById = new Map<string, TrackedToolCall>();
459
+ private readonly pendingInOrder: TrackedToolCall[] = [];
460
+ private readonly seenCalls = new Map<string, TrackedToolCall>();
461
+ private readonly seenInOrder: string[] = [];
462
+ private latest: TrackedToolCall | undefined;
463
+ private run: { key: string; tool: string; identity: string; count: number } | undefined;
464
+ private repeatSignal: ToolRepeatSignal | undefined;
465
+ private lastEventSeq = -1;
466
+
467
+ reset(): void {
468
+ this.pendingById.clear();
469
+ this.pendingInOrder.length = 0;
470
+ this.seenCalls.clear();
471
+ this.seenInOrder.length = 0;
472
+ this.latest = undefined;
473
+ this.run = undefined;
474
+ this.repeatSignal = undefined;
475
+ this.lastEventSeq = -1;
476
+ }
477
+
478
+ /** 新回合边界:清空工具态,同时把重放水位推进到 turn/start。 */
479
+ startTurn(seq: number): void {
480
+ if (!Number.isSafeInteger(seq) || seq < 0 || seq <= this.lastEventSeq) return;
481
+ this.reset();
482
+ this.lastEventSeq = seq;
483
+ }
484
+
485
+ /** 回合已结束:保留最后一次调用的护栏,丢弃不再可用的 loop 关联态。 */
486
+ resetRepeat(): void {
487
+ this.pendingById.clear();
488
+ this.pendingInOrder.length = 0;
489
+ this.seenCalls.clear();
490
+ this.seenInOrder.length = 0;
491
+ this.run = undefined;
492
+ this.repeatSignal = undefined;
493
+ }
494
+
495
+ recordCall(event: SessionEvent<'tool/call'>): boolean {
496
+ if (!this.acceptEventSeq(event.seq)) return false;
497
+ this.repeatSignal = undefined;
498
+ const data = event.data;
499
+ if (typeof data.name !== 'string') {
500
+ this.breakCorrelation();
501
+ return true;
502
+ }
503
+ const key = `${data.name}\n${typeof data.arguments === 'string' ? data.arguments : ''}`;
504
+ const callId = typeof data.callId === 'string' && data.callId !== '' ? data.callId : undefined;
505
+ const id = toolCorrelationKey(data, callId);
506
+ if (id === undefined) {
507
+ this.breakCorrelation({
508
+ id: undefined,
509
+ name: data.name,
510
+ key,
511
+ result: undefined,
512
+ resultSeq: undefined,
513
+ });
514
+ return true;
515
+ }
516
+
517
+ const seen = this.seenCalls.get(id);
518
+ if (seen !== undefined) {
519
+ // 同 seq 重放已被水位拒绝;更高 seq 复用复合 identity 时不猜测。
520
+ this.breakCorrelation({
521
+ id: undefined,
522
+ name: data.name,
523
+ key,
524
+ result: undefined,
525
+ resultSeq: undefined,
526
+ });
527
+ return true;
528
+ }
529
+
530
+ const call: TrackedToolCall = {
531
+ id,
532
+ name: data.name,
533
+ key,
534
+ result: undefined,
535
+ resultSeq: undefined,
536
+ };
537
+ this.latest = call;
538
+ this.pendingById.set(id, call);
539
+ this.pendingInOrder.push(call);
540
+ this.seenCalls.set(id, call);
541
+ this.seenInOrder.push(id);
542
+ this.trim(call);
543
+ return true;
544
+ }
545
+
546
+ recordResult(event: SessionEvent<'tool/result'>): ToolRepeatSignal | undefined {
547
+ if (!this.acceptEventSeq(event.seq)) return undefined;
548
+ const data = event.data;
549
+ const id = toolCorrelationKey(data, toolResultCallId(data));
550
+ if (id === undefined) {
551
+ this.breakCorrelation(this.latest);
552
+ return undefined;
553
+ }
554
+ const surfaceOp = event.surfaceOp;
555
+ if (typeof surfaceOp === 'object' && surfaceOp !== null) {
556
+ const call = this.seenCalls.get(id);
557
+ if (
558
+ surfaceOp.start !== surfaceOp.end ||
559
+ call === undefined ||
560
+ call.result === undefined ||
561
+ call.resultSeq !== surfaceOp.start
562
+ ) {
563
+ this.breakCorrelation(this.latest);
564
+ return undefined;
565
+ }
566
+ call.result = toolResultFacts(data);
567
+ call.resultSeq = event.seq;
568
+ // replacement 可由 lossy pruner 产生:更新护栏,但绝不据此创建/增强重复。
569
+ this.breakCorrelation(this.latest);
570
+ return undefined;
571
+ }
572
+ const call = this.pendingById.get(id);
573
+ if (call === undefined) {
574
+ const seen = this.seenCalls.get(id);
575
+ const duplicate = seen?.result;
576
+ const incoming = toolResultFacts(data);
577
+ if (
578
+ seen !== undefined &&
579
+ duplicate !== undefined &&
580
+ seen.resultSeq === event.seq &&
581
+ duplicate.identity === incoming.identity
582
+ ) {
583
+ return undefined;
584
+ }
585
+ if (seen !== undefined && duplicate !== undefined) {
586
+ // 已完成调用又出现非 replacement 冲突时,旧完成事实也不再可信。
587
+ seen.result = undefined;
588
+ seen.resultSeq = undefined;
589
+ }
590
+ this.breakCorrelation(this.latest);
591
+ return undefined;
592
+ }
593
+ if (call.result !== undefined) {
594
+ const incoming = toolResultFacts(data);
595
+ if (call.resultSeq === event.seq && call.result.identity === incoming.identity) {
596
+ return undefined;
597
+ }
598
+ // 没有 surface replacement 语义却出现第二个冲突结果:关联已不可信。
599
+ call.result = undefined;
600
+ call.resultSeq = undefined;
601
+ this.breakCorrelation(this.latest);
602
+ return undefined;
603
+ }
604
+ call.result = toolResultFacts(data);
605
+ call.resultSeq = event.seq;
606
+ return this.drainCompleted();
607
+ }
608
+
609
+ guard(): ToolGuardState {
610
+ const latest = this.latest;
611
+ if (latest === undefined) return { kind: 'none' };
612
+ if (latest.result === undefined) return { kind: 'pending', tool: latest.name };
613
+ if (latest.result.ok) {
614
+ return { kind: 'done', tool: latest.name, result: latest.result.excerpt };
615
+ }
616
+ return { kind: 'failed', tool: latest.name };
617
+ }
618
+
619
+ lastTool(): string | undefined {
620
+ return this.latest?.name;
621
+ }
622
+
623
+ /** 下一模型 step 是稳定边界;此前 replacement/新调用会先清除候选。 */
624
+ confirmRepeatAtStep(seq: number): ToolRepeatSignal | undefined {
625
+ if (!this.acceptEventSeq(seq)) return undefined;
626
+ const signal = this.pendingInOrder.length === 0 ? this.repeatSignal : undefined;
627
+ this.repeatSignal = undefined;
628
+ return signal;
629
+ }
630
+
631
+ /** 非工具 surface range replacement(如 compaction summary)同样终止旧工具证据。 */
632
+ recordSurfaceReplacement(seq: number): void {
633
+ if (!this.acceptEventSeq(seq)) return;
634
+ this.breakCorrelation(this.latest);
635
+ }
636
+
637
+ restore(events: readonly SessionEvent[], untilSeq: number): void {
638
+ this.reset();
639
+ for (const event of events) {
640
+ if (event.seq >= untilSeq) continue;
641
+ if (event.type === 'turn/start') this.startTurn(event.seq);
642
+ else if (event.type === 'step/start') this.confirmRepeatAtStep(event.seq);
643
+ else if (event.type === 'tool/call') this.recordCall(event);
644
+ else if (event.type === 'tool/result') this.recordResult(event);
645
+ else if (
646
+ (event.type === 'user/message' || event.type === 'assistant/message') &&
647
+ typeof event.surfaceOp === 'object' &&
648
+ event.surfaceOp !== null
649
+ ) {
650
+ this.recordSurfaceReplacement(event.seq);
651
+ }
652
+ }
653
+ }
654
+
655
+ private acceptEventSeq(seq: number): boolean {
656
+ if (!Number.isSafeInteger(seq) || seq < 0) {
657
+ this.breakCorrelation(this.latest);
658
+ return false;
659
+ }
660
+ // session/event 与持久日志都按 seq 单调投递;旧 seq 只能是重放帧。
661
+ if (seq <= this.lastEventSeq) return false;
662
+ this.lastEventSeq = seq;
663
+ return true;
664
+ }
665
+
666
+ private breakCorrelation(latest?: TrackedToolCall, preserve?: TrackedToolCall): void {
667
+ this.pendingById.clear();
668
+ this.pendingInOrder.length = 0;
669
+ this.invalidateRunHistory();
670
+ this.latest = latest;
671
+ if (
672
+ preserve?.id !== undefined &&
673
+ preserve.result === undefined &&
674
+ this.seenCalls.get(preserve.id) === preserve
675
+ ) {
676
+ this.pendingById.set(preserve.id, preserve);
677
+ this.pendingInOrder.push(preserve);
678
+ }
679
+ }
680
+
681
+ private invalidateRunHistory(): void {
682
+ this.run = undefined;
683
+ this.repeatSignal = undefined;
684
+ }
685
+
686
+ private trim(current: TrackedToolCall): void {
687
+ while (this.pendingInOrder.length > MAX_PENDING_TOOL_CALLS) {
688
+ // 只淘汰一个 call 会让其后的乱序结果跨过未知缺口重新拼成 streak。
689
+ this.breakCorrelation(current, current);
690
+ }
691
+ while (this.seenInOrder.length > MAX_SEEN_TOOL_CALL_IDS) {
692
+ const id = this.seenInOrder.shift();
693
+ if (id !== undefined) {
694
+ this.seenCalls.delete(id);
695
+ // 丢失去重证据后不保留旧缓存;单调 seq 会拒绝淘汰项的旧帧重放。
696
+ this.breakCorrelation(current, current);
697
+ }
698
+ }
699
+ }
700
+
701
+ private drainCompleted(): ToolRepeatSignal | undefined {
702
+ let advanced = false;
703
+ while (this.pendingInOrder[0]?.result !== undefined) {
704
+ const call = this.pendingInOrder.shift();
705
+ if (call === undefined || call.result === undefined) break;
706
+ advanced = true;
707
+ if (call.id !== undefined) this.pendingById.delete(call.id);
708
+ this.advanceRun(call);
709
+ }
710
+ // 已知并发批次尚未收齐时不提前发信号;末尾结果可能展示真实进展。
711
+ if (!advanced) return undefined;
712
+ return this.refreshRepeatSignal();
713
+ }
714
+
715
+ private advanceRun(call: TrackedToolCall): void {
716
+ if (call.result === undefined) return;
717
+ if (this.run?.key === call.key && this.run.identity === call.result.identity) {
718
+ this.run.count += 1;
719
+ } else {
720
+ this.run = { key: call.key, tool: call.name, identity: call.result.identity, count: 1 };
721
+ }
722
+ }
723
+
724
+ private refreshRepeatSignal(): ToolRepeatSignal | undefined {
725
+ this.repeatSignal =
726
+ this.pendingInOrder.length === 0 && this.run !== undefined
727
+ ? { tool: this.run.tool, count: this.run.count }
728
+ : undefined;
729
+ return this.repeatSignal;
730
+ }
336
731
  }
337
732
 
338
733
  /** 自适应退避: 同一会话连续失败时的有效冷却间隔。 */
@@ -396,12 +791,10 @@ export function emptyDayStats(): DayStats {
396
791
  export interface SessionState {
397
792
  /** 连续自动「继续」次数; 成功回合或用户手动介入后归零。 */
398
793
  consecutive: number;
399
- /** 上次自动「继续」时间戳。 */
400
- lastAutoAt: number;
401
794
  /** 上次自动「继续」尝试(成功或失败)时间戳; 防止失败场景下的快速重试循环。 */
402
795
  lastAttemptAt: number;
403
- /** 我们上次自动发送的文本(用于识别自己的回显)。 */
404
- lastSentText: string;
796
+ /** 尚未回显到会话事件流的自动发送消息 ID。 */
797
+ pendingEchoMessageIds: Map<string, number>;
405
798
  /** 宽限期定时器(进行中的待发送)。 */
406
799
  pendingTimer: ReturnType<typeof setTimeout> | undefined;
407
800
  /** 宿主权威 running 位(来自 host/session-status 与回合事件)。 */
@@ -414,10 +807,8 @@ export interface SessionState {
414
807
  lastFailure: FailureFacts | undefined;
415
808
  /** 最近一次失败的发生时间(模板 {elapsed} 与恢复统计用)。 */
416
809
  lastFailureAt: number;
417
- /** 失败前最后一次工具调用的名称(模板 {tool} 与幂等护栏用)。 */
418
- lastTool: string | undefined;
419
- /** 上一步工具调用的结果状态: 'pending' = 已发起未见结果(可能已部分执行)。 */
420
- lastToolResult: 'pending' | ToolResultFacts | undefined;
810
+ /** callId 精确配对的工具调用、幂等护栏与 loop 重复态。 */
811
+ tools: ToolInvocationTracker;
421
812
  /** 失败回合的编号(模板 {turn})。 */
422
813
  lastTurn: number | undefined;
423
814
  /** 我们最近一次自动发送的时间戳; 0 = 没有待确认的恢复。 */
@@ -430,52 +821,30 @@ export interface SessionState {
430
821
  lastAssistantText: string;
431
822
  /** 连续相同文本消息数(最强空转信号, 不限长度)。 */
432
823
  sameTextRun: number;
433
- /**
434
- * 工具重复信号(loop guard 信号 2: 死循环)。
435
- * 只有「同工具 + 同参数 + 同结果」的连续调用才累计; 参数或结果有变化视为有进展, 计数重置。
436
- */
437
- toolRun:
438
- | {
439
- /** 工具名 + 参数(用于判定是否同一调用)。 */
440
- key: string;
441
- /** 连续相同调用数(结果确认后更新)。 */
442
- count: number;
443
- /** 上次该调用的结果摘要(比较用)。 */
444
- lastResult: string | undefined;
445
- /** 本次调用等待结果确认。 */
446
- waiting: boolean;
447
- }
448
- | undefined;
449
824
  /** 本回合已触发过 loop guard(防重复打断)。 */
450
825
  loopFired: boolean;
451
826
  /** loop 重启的延迟定时器(冷却结束后再 schedule)。 */
452
827
  loopRetryTimer: ReturnType<typeof setTimeout> | undefined;
453
- /** 我们主动 cancel 过本回合(区分用户停止)。 */
454
- loopCancelled: boolean;
455
828
  }
456
829
 
457
830
  export const freshState = (): SessionState => ({
458
831
  consecutive: 0,
459
- lastAutoAt: 0,
460
832
  lastAttemptAt: 0,
461
- lastSentText: '',
833
+ pendingEchoMessageIds: new Map(),
462
834
  pendingTimer: undefined,
463
835
  running: undefined,
464
836
  queued: 0,
465
837
  subagent: false,
466
838
  lastFailure: undefined,
467
839
  lastFailureAt: 0,
468
- lastTool: undefined,
469
- lastToolResult: undefined,
840
+ tools: new ToolInvocationTracker(),
470
841
  lastTurn: undefined,
471
842
  pendingRecoveryAt: 0,
472
843
  shortRun: 0,
473
844
  lastShortAt: 0,
474
845
  lastAssistantText: '',
475
846
  sameTextRun: 0,
476
- toolRun: undefined,
477
847
  loopFired: false,
478
- loopCancelled: false,
479
848
  loopRetryTimer: undefined,
480
849
  });
481
850
 
@@ -483,16 +852,38 @@ export const freshState = (): SessionState => ({
483
852
  export const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
484
853
 
485
854
  export const ECHO_WINDOW_MS = 10 * 60 * 1000;
855
+ const MAX_PENDING_ECHO_MESSAGE_IDS = 64;
856
+
857
+ function prunePendingEchoMessageIds(state: SessionState, now: number): void {
858
+ for (const [messageId, queuedAt] of state.pendingEchoMessageIds) {
859
+ if (now - queuedAt > ECHO_WINDOW_MS) state.pendingEchoMessageIds.delete(messageId);
860
+ }
861
+ }
862
+
863
+ /** Track an identified plugin message before handing it to the host queue. */
864
+ export function trackPendingEcho(state: SessionState, messageId: string): void {
865
+ const now = Date.now();
866
+ prunePendingEchoMessageIds(state, now);
867
+ state.pendingEchoMessageIds.set(messageId, now);
868
+ while (state.pendingEchoMessageIds.size > MAX_PENDING_ECHO_MESSAGE_IDS) {
869
+ const oldest = state.pendingEchoMessageIds.keys().next();
870
+ if (oldest.done) break;
871
+ state.pendingEchoMessageIds.delete(oldest.value);
872
+ }
873
+ }
874
+
875
+ /** Roll back tracking when the host rejects a queued message. */
876
+ export function forgetPendingEcho(state: SessionState, messageId: string): void {
877
+ state.pendingEchoMessageIds.delete(messageId);
878
+ }
486
879
 
880
+ /** Match and consume one plugin-owned `user/message` event by stable message ID. */
487
881
  export function isOurEcho(state: SessionState, event: SessionEvent): boolean {
488
882
  if (event.type !== 'user/message') return false;
489
883
  const message = event.data;
490
884
  if (message.source.kind !== 'user') return false;
491
- if (state.lastSentText === '') return false;
492
- if (Date.now() - state.lastAutoAt > ECHO_WINDOW_MS) return false;
493
- const text = message.content
494
- .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
495
- .map((part) => part.text)
496
- .join('');
497
- return text === state.lastSentText;
885
+ if (state.pendingEchoMessageIds.size === 0) return false;
886
+ const now = Date.now();
887
+ prunePendingEchoMessageIds(state, now);
888
+ return state.pendingEchoMessageIds.delete(message.id);
498
889
  }