@xmanrui/dsh-im 4.38.2 → 4.39.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.
@@ -34,7 +34,7 @@ import {
34
34
  runPresetCommand,
35
35
  } from './preset-command.mjs';
36
36
  import { askInWorkspaceSession } from './workspace-session.mjs';
37
- import { HarnessApprovalQueue } from './harness-approval.mjs';
37
+ import { HarnessApprovalQueue, harnessApprovalDecision } from './harness-approval.mjs';
38
38
  import {
39
39
  BatchInputManager,
40
40
  batchInputBusyMessage,
@@ -56,6 +56,7 @@ import {
56
56
  validHarnessQuestion,
57
57
  } from './harness-question.mjs';
58
58
  import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
59
+ import { prependSpeakerLabel } from './speaker-label.mjs';
59
60
  import {
60
61
  hasReplyReference,
61
62
  promptContentForInboundMessage,
@@ -85,6 +86,8 @@ const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
85
86
  * attempts the mark stays and the message is treated as handled.
86
87
  */
87
88
  const MAX_DELIVERY_ATTEMPTS = 3;
89
+ /** How many plain-text messages one coalescing window folds into one dispatch. */
90
+ const MAX_COALESCE_MEMBERS = 8;
88
91
 
89
92
  function cleanText(value) {
90
93
  return typeof value === 'string' ? value.trim() : '';
@@ -190,10 +193,18 @@ export class TextHarnessBridge {
190
193
  #approvals;
191
194
  #batches = new BatchInputManager();
192
195
  #interactionCard;
196
+ #contextHooks = null;
193
197
  // Delivery attempts per message id. A failed turn releases its `markSeen` so
194
198
  // the next poll can retry, and this counter is what keeps that from becoming
195
199
  // an infinite loop on a mail that always fails.
196
200
  #deliveryAttempts = new Map();
201
+ // Who opened the in-flight turn per conversation key, for the same-sender
202
+ // auto-steer lane; and the open coalescing windows awaiting their flush.
203
+ #turnAuthors = new Map();
204
+ #coalescing = new Map();
205
+ #autoSteerSameSender = false;
206
+ #autoSteerLimit = 0;
207
+ #coalesceWindowMs = 0;
197
208
 
198
209
  constructor({
199
210
  descriptor,
@@ -209,6 +220,10 @@ export class TextHarnessBridge {
209
220
  signal,
210
221
  keepaliveIntervalMs = 4_000,
211
222
  interactionCard = null,
223
+ contextHooks = null,
224
+ autoSteerSameSender = false,
225
+ autoSteerLimit = 0,
226
+ coalesceWindowMs = 0,
212
227
  }) {
213
228
  if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
214
229
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
@@ -226,6 +241,23 @@ export class TextHarnessBridge {
226
241
  this.#signal = signal;
227
242
  this.#keepaliveIntervalMs = keepaliveIntervalMs;
228
243
  this.#interactionCard = interactionCard ?? null;
244
+ // Opt-in follow-up lanes for channels whose groups run on rapid consecutive
245
+ // posts: fold a same sender's burst into one dispatch, and steer that
246
+ // sender's own mid-turn follow-ups into the in-flight turn instead of
247
+ // queueing them behind the answer they are meant to correct. Off unless a
248
+ // channel asks for them, so every other channel keeps the plain queue.
249
+ this.#autoSteerSameSender = autoSteerSameSender === true;
250
+ this.#autoSteerLimit = Number.isSafeInteger(autoSteerLimit) && autoSteerLimit > 0
251
+ ? Math.min(autoSteerLimit, 20)
252
+ : 0;
253
+ this.#coalesceWindowMs = Number.isSafeInteger(coalesceWindowMs) && coalesceWindowMs > 0
254
+ ? Math.min(coalesceWindowMs, 3_000)
255
+ : 0;
256
+ // Channel-supplied conversation context pipeline (collect/render/consume/deliver).
257
+ // Channels own what context means and how it is framed; the bridge only runs
258
+ // the steps at the right points of one turn, so every hook is optional and a
259
+ // channel without one pays no context work at all.
260
+ this.#contextHooks = contextHooks && typeof contextHooks === 'object' ? contextHooks : null;
229
261
  this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
230
262
  deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
231
263
  });
@@ -410,6 +442,31 @@ export class TextHarnessBridge {
410
442
  this.#commandTasks.add(task);
411
443
  return task;
412
444
  }
445
+ // An addressed plain-text request that is no approve/reject token while only
446
+ // an approval waits is the sender moving on, not a decision: reject the
447
+ // waiting tool call and run this message as its own turn, so the room is not
448
+ // held behind a decision that is not coming. A pending question keeps its
449
+ // ordinary flow — the asking actor's @ text answers it, and other senders
450
+ // queue behind the turn (questions belong to the asking actor). Direct chats
451
+ // keep the plain decide-by-token path; their exits are the commands.
452
+ const addressedTrigger = normalized.kind === 'group' && normalized.addressed === true
453
+ && normalized.plainText !== false
454
+ && !hasInboundImages(normalized) && !hasInboundFiles(normalized)
455
+ && Boolean(text);
456
+ if (addressedTrigger && !pending
457
+ && this.#approvals.hasPending(key) && harnessApprovalDecision(text) === null) {
458
+ let task;
459
+ task = this.#retireWaitingInteractions(key, pending)
460
+ .then(() => this.#bot.sendText(normalized.replyTarget, t('已不再等待原有的待回答或待审批,已将其取消,先回应你的新请求。')))
461
+ .catch(() => undefined)
462
+ .then(() => this.#dispatchTrigger(normalized, messageId, senderId, key))
463
+ .finally(() => {
464
+ this.#acceptedMessageIds.delete(messageId);
465
+ this.#commandTasks.delete(task);
466
+ });
467
+ this.#commandTasks.add(task);
468
+ return task;
469
+ }
413
470
  const approval = this.#approvals.claimReply({
414
471
  key,
415
472
  actor: senderId,
@@ -474,35 +531,187 @@ export class TextHarnessBridge {
474
531
  pending.queue = current;
475
532
  return current;
476
533
  }
477
- return this.#busyMessages.dispatch({
478
- key, messageId, text: normalized.content,
479
- commandText: text, mode: busyMessageMode,
480
- onFailure: (error) => this.#signal?.aborted || error?.code === 'turn-stopped'
481
- ? normalized.statusReaction?.clear() : normalized.statusReaction?.error(),
482
- eligible: () => normalized.plainText !== false
483
- && !hasInboundImages(normalized) && !hasInboundFiles(normalized)
484
- && !hasReplyReference(normalized)
485
- && canAutomaticallySteer(this.#accessPolicy, normalized.kind,
486
- [senderId, normalized.senderAlternateId].filter(Boolean)),
487
- harness: this.#harness, state: this.#state, status: this.#status,
488
- signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
489
- acceptedMessageIds: this.#acceptedMessageIds,
490
- pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
491
- enhancement: {
492
- snapshot: this.#acceptedMessageIds.get(messageId),
493
- source: () => {
494
- const source = normalized.contextSource?.();
495
- return { channel: this.#descriptor.key, senderId, senderName: source?.senderName,
496
- conversationTitle: source?.conversationTitle,
497
- chatId: source?.chatId ?? normalized.conversationId, threadId: source?.threadId };
534
+ return this.#dispatchTrigger(normalized, messageId, senderId, key, {
535
+ // Ordinary destinations of the lanes fall through to the busy-mode dispatcher,
536
+ // keeping its steering, receipts, and routing serialization for what the lanes decline.
537
+ exit: (exitOptions) => this.#busyMessages.dispatch({
538
+ key, messageId, text: normalized.content,
539
+ commandText: text, mode: busyMessageMode,
540
+ onFailure: (error) => this.#signal?.aborted || error?.code === 'turn-stopped'
541
+ ? normalized.statusReaction?.clear() : normalized.statusReaction?.error(),
542
+ eligible: () => normalized.plainText !== false
543
+ && !hasInboundImages(normalized) && !hasInboundFiles(normalized)
544
+ && !hasReplyReference(normalized)
545
+ && canAutomaticallySteer(this.#accessPolicy, normalized.kind,
546
+ [senderId, normalized.senderAlternateId].filter(Boolean)),
547
+ harness: this.#harness, state: this.#state, status: this.#status,
548
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
549
+ acceptedMessageIds: this.#acceptedMessageIds,
550
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
551
+ enhancement: {
552
+ snapshot: this.#acceptedMessageIds.get(messageId),
553
+ source: () => {
554
+ const source = normalized.contextSource?.();
555
+ return { channel: this.#descriptor.key, senderId, senderName: source?.senderName,
556
+ conversationTitle: source?.conversationTitle,
557
+ chatId: source?.chatId ?? normalized.conversationId, threadId: source?.threadId };
558
+ },
498
559
  },
499
- },
500
- isQueued: () => this.#queues.has(key),
501
- enqueue: (options) => this.#enqueueMessage(normalized, messageId, senderId, key, options),
502
- send: (reply) => this.#bot.sendText(normalized.replyTarget, reply),
560
+ isQueued: () => this.#queues.has(key),
561
+ enqueue: (enqueueOptions) => this.#enqueueMessage(normalized, messageId, senderId, key, enqueueOptions),
562
+ send: (reply) => this.#bot.sendText(normalized.replyTarget, reply),
563
+ }),
503
564
  });
504
565
  }
505
566
 
567
+ /**
568
+ * The ordinary trigger exit, where a message becomes a turn. Three lanes:
569
+ * while the conversation runs, a plain-text follow-up from the very sender
570
+ * who opened the in-flight turn steers that turn (the room hears the
571
+ * correction at the next model step instead of after the whole answer);
572
+ * on an idle conversation, consecutive plain-text posts from one sender fold
573
+ * into a single dispatch inside a short window, so a burst split across
574
+ * bubbles becomes one turn; everything else — other senders, media, composed
575
+ * payloads — keeps the per-conversation queue that preserves one voice at a
576
+ * time.
577
+ * An `exit` hook, when provided, receives every ordinary queue destination of
578
+ * the lanes, so a channel can fall through to richer routing (busy-mode steering
579
+ * and queue receipts) for what these lanes decline.
580
+ */
581
+ #dispatchTrigger(message, messageId, senderId, key, { exit } = {}) {
582
+ const toQueue = (options) => (exit
583
+ ? exit(options)
584
+ : this.#enqueueMessage(message, messageId, senderId, key, options));
585
+ const plainText = !hasInboundImages(message) && !hasInboundFiles(message)
586
+ && message.plainText !== false
587
+ && Boolean(controlTextOf(message));
588
+ const author = this.#turnAuthors.get(key);
589
+ if (author !== undefined && plainText
590
+ && this.#autoSteerSameSender && this.#autoSteerLimit > 0
591
+ && author.senderId === senderId && author.steered < this.#autoSteerLimit) {
592
+ const text = this.#steerTextFor(message, messageId, senderId);
593
+ const sessionId = this.#state.sessionFor?.(key) ?? null;
594
+ if (text && sessionId !== null && typeof this.#harness.steerActiveTurn === 'function') {
595
+ return this.#harness
596
+ .steerActiveTurn(sessionId, text, { owner: this, key }, this.#signal ? { signal: this.#signal } : undefined)
597
+ .then((steered) => (steered
598
+ ? this.#settleSteeredTurn(author, messageId, key)
599
+ : toQueue()))
600
+ .catch(() => toQueue());
601
+ }
602
+ }
603
+ if (!this.#queues.has(key) && !this.#pendingInteractions.has(key) && !this.#approvals.hasPending(key)) {
604
+ const open = this.#coalescing.get(key);
605
+ if (open !== undefined) {
606
+ if (open.senderId === senderId && plainText && open.members.length < MAX_COALESCE_MEMBERS) {
607
+ open.members.push({ message, messageId });
608
+ return Promise.resolve();
609
+ }
610
+ // A different sender, or a payload the window may not fold: this
611
+ // message dispatches on its own while the window keeps collecting.
612
+ return toQueue();
613
+ }
614
+ if (this.#coalesceWindowMs > 0 && plainText) {
615
+ const opened = { senderId, members: [{ message, messageId }] };
616
+ const timer = setTimeout(() => { void this.#flushCoalesced(key); }, this.#coalesceWindowMs);
617
+ timer.unref?.();
618
+ this.#coalescing.set(key, opened);
619
+ return Promise.resolve();
620
+ }
621
+ }
622
+ return toQueue();
623
+ }
624
+
625
+ /**
626
+ * Tag a turn's own delivery target with its conversation key so the channel can
627
+ * retire the resulting bubble from that conversation's ambient window: the text
628
+ * is in the Session transcript already. Local notices, command output, and
629
+ * interaction prompts send on unstamped targets and stay readable as room facts.
630
+ */
631
+ #turnDeliveryTarget(target, conversationKey) {
632
+ if (!target || typeof target !== 'object'
633
+ || typeof conversationKey !== 'string' || !conversationKey) return target;
634
+ return { ...target, dshTurnKey: conversationKey };
635
+ }
636
+
637
+ /** The model-facing body of an auto-steered follow-up, labelled like a turn. */
638
+ #steerTextFor(message, messageId, senderId) {
639
+ const body = cleanText(message.content) || controlTextOf(message);
640
+ if (!body) return null;
641
+ const labelled = message.kind === 'group' ? prependSpeakerLabel(body, message.speakerName) : body;
642
+ const snapshot = this.#acceptedMessageIds.get(messageId);
643
+ if (!snapshot) return labelled;
644
+ // A mid-turn follow-up carries the same provenance as the message that
645
+ // opened the turn, so the member who steers is identified to the model too.
646
+ const contextSource = message.contextSource?.();
647
+ return enhanceContextContent(labelled, snapshot, () => ({
648
+ channel: this.#descriptor.key,
649
+ senderId,
650
+ senderName: contextSource?.senderName,
651
+ conversationTitle: contextSource?.conversationTitle,
652
+ chatId: contextSource?.chatId ?? message.conversationId,
653
+ threadId: contextSource?.threadId,
654
+ }));
655
+ }
656
+
657
+ /** A steered message became part of the in-flight turn: settle its receipt. */
658
+ async #settleSteeredTurn(author, messageId, key) {
659
+ author.steered += 1;
660
+ if (!this.#state.hasSeen(messageId)) await this.#state.markSeen(messageId);
661
+ this.#status.messagesReceived += 1;
662
+ this.#status.lastMessageAt = new Date().toISOString();
663
+ this.#acceptedMessageIds.delete(messageId);
664
+ // The steered body now lives in the in-flight turn, just like a folded
665
+ // coalescer member: the channel retires its bubble from the ambient window
666
+ // with the turn instead of re-serving text this session has taken in.
667
+ if (this.#contextHooks?.onSteered) {
668
+ void Promise.resolve(this.#contextHooks.onSteered({ conversationKey: key, messageId }))
669
+ .catch((error) => {
670
+ this.#logger.warn?.(
671
+ `[dsh-im:${this.#descriptor.key}] could not retire a steered follow-up from the ambient window:`,
672
+ error?.message ?? error,
673
+ );
674
+ });
675
+ }
676
+ return true;
677
+ }
678
+
679
+ /**
680
+ * Dispatch a folded window as one turn: the merged body carries every
681
+ * member's text, the answer targets the newest message, and the extra
682
+ * members travel as `coalescedEventIds` so the channel ledger can retire
683
+ * them from its ambient window before it re-serves what this turn already
684
+ * says.
685
+ */
686
+ async #flushCoalesced(key) {
687
+ const open = this.#coalescing.get(key);
688
+ if (open === undefined) return;
689
+ this.#coalescing.delete(key);
690
+ if (this.#signal?.aborted) {
691
+ for (const member of open.members) this.#acceptedMessageIds.delete(member.messageId);
692
+ return;
693
+ }
694
+ const bodies = [];
695
+ for (const member of open.members) {
696
+ const body = cleanText(member.message.content) || controlTextOf(member.message);
697
+ if (body && !bodies.includes(body)) bodies.push(body);
698
+ this.#acceptedMessageIds.delete(member.messageId);
699
+ if (!this.#state.hasSeen(member.messageId)) await this.#state.markSeen(member.messageId);
700
+ this.#status.messagesReceived += 1;
701
+ }
702
+ if (bodies.length === 0) return;
703
+ this.#status.lastMessageAt = new Date().toISOString();
704
+ const last = open.members[open.members.length - 1];
705
+ const merged = {
706
+ ...last.message,
707
+ content: bodies.join('\n\n'),
708
+ ...(open.members.length > 1
709
+ ? { coalescedEventIds: open.members.slice(0, -1).map((member) => member.messageId) }
710
+ : {}),
711
+ };
712
+ return this.#enqueueMessage(merged, last.messageId, open.senderId, key, { alreadyRecorded: true });
713
+ }
714
+
506
715
  #finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
507
716
  let task;
508
717
  task = (async () => {
@@ -572,6 +781,13 @@ export class TextHarnessBridge {
572
781
  )),
573
782
  ...this.#approvalTasks,
574
783
  ...this.#commandTasks,
784
+ // An unexpired coalescing window drains through its own timer: wait out
785
+ // its worst-case life so a shutdown never drops folded messages before
786
+ // the flush enqueues them for the queue wait below to cover.
787
+ ...this.#coalescing.values().map(() => new Promise((resolve) => {
788
+ const timer = setTimeout(resolve, this.#coalesceWindowMs + 25);
789
+ timer.unref?.();
790
+ })),
575
791
  ]);
576
792
  await this.#deferred.whenIdle();
577
793
  }
@@ -759,6 +975,14 @@ export class TextHarnessBridge {
759
975
  }
760
976
  if (!hasImages && !hasFiles && command === '/new') {
761
977
  await this.#state.clearSession(conversationKey);
978
+ if (this.#contextHooks?.onSessionCleared) {
979
+ try {
980
+ await this.#contextHooks.onSessionCleared(conversationKey);
981
+ } catch (error) {
982
+ // The cleared session is already done; a failed context reset must not block the command.
983
+ this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] failed to reset context anchors after /new:`, error?.message ?? error);
984
+ }
985
+ }
762
986
  await this.#bot.sendText(target, t('已开启新会话。请发送你的问题。'));
763
987
  return;
764
988
  }
@@ -782,7 +1006,7 @@ export class TextHarnessBridge {
782
1006
  let streamFinished = false;
783
1007
  if (this.#thinkingTraces && typeof this.#bot.openThinkingStream === 'function') {
784
1008
  try {
785
- stream = await this.#bot.openThinkingStream(target);
1009
+ stream = await this.#bot.openThinkingStream(this.#turnDeliveryTarget(target, conversationKey));
786
1010
  thinkingStream = true;
787
1011
  } catch (error) {
788
1012
  stream = null;
@@ -795,7 +1019,7 @@ export class TextHarnessBridge {
795
1019
  if (!stream) {
796
1020
  if (typeof this.#bot.openDeliveryStream === 'function') {
797
1021
  try {
798
- stream = await this.#bot.openDeliveryStream(target);
1022
+ stream = await this.#bot.openDeliveryStream(this.#turnDeliveryTarget(target, conversationKey));
799
1023
  semanticStream = true;
800
1024
  } catch (error) {
801
1025
  this.#logger.warn?.(
@@ -805,7 +1029,7 @@ export class TextHarnessBridge {
805
1029
  }
806
1030
  } else if (typeof this.#bot.openStream === 'function') {
807
1031
  try {
808
- stream = await this.#bot.openStream(target);
1032
+ stream = await this.#bot.openStream(this.#turnDeliveryTarget(target, conversationKey));
809
1033
  } catch (error) {
810
1034
  this.#logger.warn?.(
811
1035
  `[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
@@ -825,6 +1049,11 @@ export class TextHarnessBridge {
825
1049
  let content = hasImages || hasReply
826
1050
  ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
827
1051
  : cleanText(message.content) || undefined;
1052
+ // A room conversation is shared by everyone who addresses the bot in it, so
1053
+ // the delivered prompt says whose turn this is. Only the model-facing content
1054
+ // is labelled; the parsed control text, commands, and ledger keys keep the
1055
+ // unlabelled body they were read from.
1056
+ if (message.kind === 'group') content = prependSpeakerLabel(content, message.speakerName);
828
1057
  const snapshot = this.#acceptedMessageIds.get(messageId);
829
1058
  let contextEnhanced = false;
830
1059
  if (snapshot) {
@@ -840,6 +1069,28 @@ export class TextHarnessBridge {
840
1069
  }));
841
1070
  contextEnhanced = content !== originalContent;
842
1071
  }
1072
+ // Collected after command handling so control commands never pay for a
1073
+ // context lookup, and only for a plain-text prompt: a composed or media
1074
+ // payload already carries its own structure and stays untouched.
1075
+ let contextEntries = null;
1076
+ if (this.#contextHooks?.collect && this.#contextHooks.enabled?.() !== false && !batchSubmission
1077
+ && (content === undefined || typeof content === 'string') && !hasImages && !hasFiles) {
1078
+ try {
1079
+ const collected = await this.#contextHooks.collect({
1080
+ message,
1081
+ conversationKey,
1082
+ sessionIdHint: this.#state.sessionFor?.(conversationKey) ?? null,
1083
+ });
1084
+ if (Array.isArray(collected) && collected.length > 0) {
1085
+ contextEntries = collected;
1086
+ const block = this.#contextHooks.render?.(collected) ?? '';
1087
+ if (block) content = `${block}\n\n${content ?? ''}`;
1088
+ }
1089
+ } catch (error) {
1090
+ // Context is an enhancement: a collector failure must not lose the user's own turn.
1091
+ this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] context collection failed; prompting without context:`, error?.message ?? error);
1092
+ }
1093
+ }
843
1094
  // Start the keepalive only after the inbound payload is ready, so a
844
1095
  // pre-prompt failure (image parsing, context building) cannot leave the
845
1096
  // timer running; the outermost finally below clears it on every path.
@@ -863,7 +1114,11 @@ export class TextHarnessBridge {
863
1114
  }, this.#keepaliveIntervalMs);
864
1115
  keepaliveTimer.unref?.();
865
1116
  }
866
- const { answer, artifacts = [] } = await askInWorkspaceSession({
1117
+ // From here the conversation has a turn in flight that this sender
1118
+ // opened: the auto-steer lane keys its authorship decision off this
1119
+ // record, and the finally below retires it when the turn settles.
1120
+ this.#turnAuthors.set(conversationKey, { senderId, steered: 0, turnMessageId: messageId });
1121
+ const { sessionId, answer, artifacts = [] } = await askInWorkspaceSession({
867
1122
  deferredDelivery: () => ({ coordinator: this.#deferred, target: this.#descriptor.key === 'whatsapp' ? { jid: target.jid, selfChat: target.selfChat } : target }),
868
1123
  harness: this.#harness,
869
1124
  state: this.#state,
@@ -927,6 +1182,16 @@ export class TextHarnessBridge {
927
1182
  if (batchSubmission) {
928
1183
  this.#batches.complete(conversationKey, batchSubmission.token);
929
1184
  }
1185
+ // The completed turn consumed its context: ambient records stop being offered
1186
+ // and a thread-root anchor is stamped for this session. A failed ask throws
1187
+ // past this point, so a failed turn re-offers its context on the next message.
1188
+ if (contextEntries && this.#contextHooks?.onConsumed) {
1189
+ try {
1190
+ await this.#contextHooks.onConsumed({ conversationKey, sessionId, entries: contextEntries });
1191
+ } catch (error) {
1192
+ this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] context consumption marking failed:`, error?.message ?? error);
1193
+ }
1194
+ }
930
1195
  const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
931
1196
  const visibleAnswer = fileOnlyCompletion
932
1197
  ? t(FILE_ONLY_COMPLETION_TEXT)
@@ -962,12 +1227,13 @@ export class TextHarnessBridge {
962
1227
  }
963
1228
  if (!streamFinished) {
964
1229
  try {
1230
+ const answerTarget = this.#turnDeliveryTarget(target, conversationKey);
965
1231
  const result = typeof this.#bot.sendDelivery === 'function'
966
1232
  ? await this.#bot.sendDelivery(
967
- target,
1233
+ answerTarget,
968
1234
  createTextDeliveryBlock(visibleAnswer, answerFormat),
969
1235
  )
970
- : await this.#bot.sendText(target, visibleAnswer);
1236
+ : await this.#bot.sendText(answerTarget, visibleAnswer);
971
1237
  textReceipt = createDeliveryReceipt({
972
1238
  deliveryId: messageId,
973
1239
  presentation: result?.presentation ?? `${this.#descriptor.key}-text`,
@@ -1010,6 +1276,22 @@ export class TextHarnessBridge {
1010
1276
  }
1011
1277
  // The turn produced an answer, so its retry budget is spent and reset.
1012
1278
  this.#deliveryAttempts.delete(messageId);
1279
+ if (this.#contextHooks?.onDelivered) {
1280
+ const deliveredEventIds = [
1281
+ ...new Set([
1282
+ ...(Array.isArray(textReceipt?.providerMessageIds) ? textReceipt.providerMessageIds : []),
1283
+ ...(Array.isArray(delivery.receipt?.providerMessageIds) ? delivery.receipt.providerMessageIds : []),
1284
+ ]),
1285
+ ];
1286
+ if (deliveredEventIds.length > 0) {
1287
+ try {
1288
+ await this.#contextHooks.onDelivered({ conversationKey, sessionId, providerMessageIds: deliveredEventIds });
1289
+ } catch (error) {
1290
+ // A missing binding only costs reply classification quality for these events; the answer itself reached the room.
1291
+ this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] bot event binding recording failed:`, error?.message ?? error);
1292
+ }
1293
+ }
1294
+ }
1013
1295
  return delivery.receipt;
1014
1296
  } catch (error) {
1015
1297
  stopKeepalive();
@@ -1088,6 +1370,8 @@ export class TextHarnessBridge {
1088
1370
  return error.deliveryReceipt;
1089
1371
  } finally {
1090
1372
  stopKeepalive();
1373
+ const inFlight = this.#turnAuthors.get(conversationKey);
1374
+ if (inFlight?.turnMessageId === messageId) this.#turnAuthors.delete(conversationKey);
1091
1375
  await Promise.allSettled([
1092
1376
  this.#cancelPendingInteraction(conversationKey),
1093
1377
  this.#approvals.closeRoute(conversationKey),
@@ -1672,4 +1956,36 @@ export class TextHarnessBridge {
1672
1956
  );
1673
1957
  }
1674
1958
  }
1959
+
1960
+ /**
1961
+ * Retire what waits on a human decision in this conversation: the pending
1962
+ * question loses its card and gets the same safe cancellation the recovered
1963
+ * path uses, and active tool approvals are rejected. The caller follows it
1964
+ * with the new request's dispatch, so the room hears the answer instead of
1965
+ * the waiting.
1966
+ */
1967
+ async #retireWaitingInteractions(key, pending) {
1968
+ if (pending) {
1969
+ this.#clearPendingInteraction(key, pending.interactionId);
1970
+ pending.claimedReplyMessageId = null;
1971
+ if (pending.cardMessageId) {
1972
+ await this.#retireInteractionCard(pending.target, pending.cardMessageId);
1973
+ pending.cardMessageId = null;
1974
+ }
1975
+ if (pending.kind === 'question') {
1976
+ try {
1977
+ await this.#respondCancellation(
1978
+ pending.interaction,
1979
+ `The ${this.#descriptor.label} sender moved on to a new request before answering.`,
1980
+ );
1981
+ } catch (error) {
1982
+ this.#logger.warn?.(
1983
+ `[dsh-im:${this.#descriptor.key}] failed to cancel an unanswered pending question:`,
1984
+ error?.message ?? error,
1985
+ );
1986
+ }
1987
+ }
1988
+ }
1989
+ await this.#approvals.rejectRoute(key).catch(() => 0);
1990
+ }
1675
1991
  }
@@ -48,7 +48,7 @@ export function wecomMenu({ workspace, workspaces = [] } = {}) {
48
48
  entries: [
49
49
  [t('切换工作区'), 'select:workspace'],
50
50
  [t('🆕 新会话'), '/new'],
51
- [t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'], [t('🧹 清空上下文'), '/clear'],
51
+ [t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'],
52
52
  [t('📊 状态'), '/status'], [t('📖 帮助'), '/help'],
53
53
  ],
54
54
  };