@xmanrui/dsh-im 4.12.0 → 4.14.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.
Files changed (47) hide show
  1. package/PROACTIVE_DELIVERY.en.md +4 -0
  2. package/PROACTIVE_DELIVERY.md +4 -0
  3. package/README.en.md +11 -2
  4. package/README.md +11 -2
  5. package/assets/dingtalk-menu-template.json +6 -0
  6. package/lib/client.js +78 -2
  7. package/lib/index.js +273 -264
  8. package/package.json +1 -1
  9. package/plugin-src/client/channels/feishu/api.js +2 -0
  10. package/plugin-src/client/channels/feishu/index.js +57 -0
  11. package/plugin-src/client/i18n.js +5 -0
  12. package/plugin-src/client/styles.js +2 -1
  13. package/plugin-src/host/channels/feishu/production.mjs +1 -0
  14. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  15. package/plugin-src/host/harness-command-executor.mjs +13 -6
  16. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  17. package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
  18. package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
  19. package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
  20. package/src/channels/dingtalk/state-store.mjs +8 -0
  21. package/src/channels/feishu/bridge.mjs +584 -6
  22. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  23. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  24. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  25. package/src/channels/feishu/state-store.mjs +11 -0
  26. package/src/channels/qq/qq-bridge.mjs +121 -1
  27. package/src/channels/qq/qq-menu.mjs +205 -0
  28. package/src/channels/qq/state-store.mjs +16 -0
  29. package/src/channels/shared/bot-workspace-store.mjs +17 -1
  30. package/src/channels/shared/control-command.mjs +8 -0
  31. package/src/channels/shared/conversation-state-store.mjs +8 -0
  32. package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
  33. package/src/channels/shared/deferred-delivery.mjs +161 -0
  34. package/src/channels/shared/deferred-state.mjs +53 -0
  35. package/src/channels/shared/harness-client.mjs +42 -3
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
  37. package/src/channels/shared/i18n-en/feishu.mjs +8 -0
  38. package/src/channels/shared/i18n-en/qq.mjs +39 -0
  39. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  40. package/src/channels/shared/semantic/artifact.mjs +3 -1
  41. package/src/channels/shared/text-harness-bridge.mjs +15 -0
  42. package/src/channels/shared/workspace-session.mjs +16 -1
  43. package/src/channels/wecom/state-store.mjs +8 -0
  44. package/src/channels/wecom/wecom-bridge.mjs +19 -4
  45. package/src/channels/weixin/state-store.mjs +64 -1
  46. package/src/channels/weixin/weixin-bridge.mjs +31 -5
  47. package/src/channels/weixin/weixin-runtime.mjs +33 -8
@@ -56,6 +56,7 @@ import {
56
56
  workspacePathSnapshot,
57
57
  } from '../shared/workspace-command.mjs';
58
58
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
59
+ import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
59
60
  import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
60
61
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
61
62
  import {
@@ -156,6 +157,77 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
156
157
  /** Matches fast card commands that should not be queued behind a running task. */
157
158
  const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
158
159
 
160
+ // ── Step push (分步直推) limits ──────────────────────────────────────────────
161
+ /** Feishu allows 5 msg/s per chat; step push stays well below that. */
162
+ const STEP_PUSH_MIN_INTERVAL_MS = 250;
163
+ /** One turn never pushes more step messages than this (熔断阈值). */
164
+ const STEP_PUSH_MAX_MESSAGES_PER_TURN = 200;
165
+ /** Excerpt bounds for step push message rendering. */
166
+ const STEP_PUSH_SUMMARY_MAX_CHARS = 120;
167
+ const STEP_PUSH_ARGUMENTS_MAX_CHARS = 400;
168
+ const STEP_PUSH_ERROR_MAX_CHARS = 200;
169
+ /** One post message carries at most this many UTF-8 bytes after its rich-text
170
+ * content has been JSON encoded (Feishu caps rich-text requests at 30KB). */
171
+ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
172
+
173
+ /** Split one markdown answer into post-sized chunks at paragraph bounds,
174
+ * budgeted by the encoded rich-text content rather than the source string.
175
+ * This accounts for both CJK bytes and JSON escape expansion. */
176
+ function splitStepPostMarkdown(text, limit = STEP_PUSH_POST_CHUNK_MAX_BYTES) {
177
+ const source = String(text ?? '');
178
+ const encodedBytes = (markdown) => Buffer.byteLength(JSON.stringify({
179
+ zh_cn: { content: [[{ tag: 'md', text: markdown }]] },
180
+ }), 'utf8');
181
+ if (encodedBytes(source) <= limit) return [source];
182
+ const chunks = [];
183
+ let remaining = source;
184
+ while (encodedBytes(remaining) > limit) {
185
+ let lo = 0;
186
+ let hi = remaining.length;
187
+ while (lo < hi) {
188
+ const mid = (lo + hi + 1) >> 1;
189
+ if (encodedBytes(remaining.slice(0, mid)) <= limit) lo = mid;
190
+ else hi = mid - 1;
191
+ }
192
+ let cut = lo;
193
+ const preferFrom = Math.floor(cut * 0.6);
194
+ const softCut = remaining.lastIndexOf('\n\n', cut);
195
+ if (softCut >= preferFrom) {
196
+ cut = softCut;
197
+ } else {
198
+ const lineCut = remaining.lastIndexOf('\n', cut);
199
+ if (lineCut >= preferFrom) cut = lineCut;
200
+ }
201
+ if (cut <= 0) cut = lo;
202
+ // JavaScript indexes strings by UTF-16 code unit. Never split between a
203
+ // high and low surrogate, which would turn one emoji into two replacement
204
+ // characters across adjacent messages.
205
+ const before = remaining.charCodeAt(cut - 1);
206
+ const after = remaining.charCodeAt(cut);
207
+ if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
208
+ cut -= 1;
209
+ }
210
+ if (cut <= 0) {
211
+ throw new RangeError('Feishu step push post byte limit is too small');
212
+ }
213
+ chunks.push(remaining.slice(0, cut));
214
+ remaining = remaining.slice(cut).trimStart();
215
+ }
216
+ if (remaining) chunks.push(remaining);
217
+ return chunks;
218
+ }
219
+ /** Salient tool-argument fields for the intent summary, in priority order. */
220
+ const STEP_PUSH_INTENT_FIELDS = [
221
+ 'command', 'file_path', 'path', 'query', 'url', 'pattern', 'prompt', 'cmd',
222
+ ];
223
+ /** Bounds the per-conversation step push send-state cache. */
224
+ const MAX_STEP_PUSH_SEND_STATES = 512;
225
+ /** Real clock used by step push throttling; tests inject a fake one. */
226
+ const DEFAULT_STEP_PUSH_CLOCK = Object.freeze({
227
+ now: () => Date.now(),
228
+ delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
229
+ });
230
+
159
231
  /** Pretty-print a tool call's arguments for an approval card. */
160
232
  function operationArguments(toolCall) {
161
233
  const source = toolCall?.arguments;
@@ -246,6 +318,11 @@ function answerTextForDelivery(answer, artifacts) {
246
318
  return artifacts.length > 0 ? t('结果文件已生成。') : answer;
247
319
  }
248
320
 
321
+ /** Collapse a step push excerpt into one tidy line. */
322
+ function stepPushLine(value) {
323
+ return String(value ?? '').replace(/\s+/gu, ' ').trim();
324
+ }
325
+
249
326
  function nonEmptyString(value) {
250
327
  return typeof value === 'string' && value.trim() ? value.trim() : null;
251
328
  }
@@ -488,6 +565,12 @@ export class FeishuHarnessBridge {
488
565
  #groupResponseMode;
489
566
  /** When true, group replies that belong to a Feishu topic ask reply_in_thread. */
490
567
  #groupTopicReply = false;
568
+ /** When true, streaming turns push tool calls and interim notes as discrete messages. */
569
+ #stepPush = false;
570
+ /** Per-conversation step push state: key → { lastSentAt, count, breakerLogged }. */
571
+ #stepPushSendState = new Map();
572
+ /** Injectable clock for step push throttling (tests pass a fake one). */
573
+ #stepPushClock;
491
574
  /** Group chats this bot has seen; only these may use topic replies. */
492
575
  #groupChatIds = new Set();
493
576
  /** Anchor message id → whether its replies should stay in a Feishu topic. */
@@ -505,6 +588,7 @@ export class FeishuHarnessBridge {
505
588
  #cardKeys = new Map();
506
589
  /** The global event-mux watcher (one per bridge). */
507
590
  #eventWatcher = null;
591
+ #deferred;
508
592
  /** Serializes completion work per session without blocking unrelated sessions. */
509
593
  #eventTails = new Map();
510
594
  /** Coalesces baseline compensation and records whether a trailing pass is needed. */
@@ -531,6 +615,8 @@ export class FeishuHarnessBridge {
531
615
  botOpenId,
532
616
  groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
533
617
  groupTopicReply = false,
618
+ stepPush = false,
619
+ stepPushClock = null,
534
620
  repair,
535
621
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
536
622
  repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
@@ -558,6 +644,12 @@ export class FeishuHarnessBridge {
558
644
  || !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0) {
559
645
  throw new TypeError('Feishu timing values must be positive numbers');
560
646
  }
647
+ if (stepPushClock !== null
648
+ && (typeof stepPushClock !== 'object' || Array.isArray(stepPushClock)
649
+ || typeof stepPushClock.now !== 'function'
650
+ || typeof stepPushClock.delay !== 'function')) {
651
+ throw new TypeError('Feishu step push clock requires now and delay functions');
652
+ }
561
653
  this.#client = client;
562
654
  this.#channel = channel;
563
655
  this.#harness = harness;
@@ -571,6 +663,8 @@ export class FeishuHarnessBridge {
571
663
  this.#botOpenId = nonEmptyString(botOpenId);
572
664
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
573
665
  this.#groupTopicReply = groupTopicReply === true;
666
+ this.#stepPush = stepPush === true;
667
+ this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
574
668
  this.#repair = repair ?? null;
575
669
  this.#repairPollIntervalMs = repairPollIntervalMs;
576
670
  this.#repairLinkWaitMs = repairLinkWaitMs;
@@ -581,10 +675,16 @@ export class FeishuHarnessBridge {
581
675
  this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
582
676
  this.#signal = signal;
583
677
  ensureStatus(this.#status);
678
+ this.#deferred = createDeferredDeliveryCoordinator({
679
+ harness, state, signal, logger, watch: false,
680
+ deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
681
+ });
584
682
  // Persisted watches must resume at runtime start, not on the first
585
683
  // message. Older hosts without the mux watcher simply skip this.
586
684
  if (typeof this.#harness?.watchHarnessEvents === 'function') {
587
- queueMicrotask(() => this.#ensureEventWatcher());
685
+ queueMicrotask(() => {
686
+ this.#ensureEventWatcher();
687
+ });
588
688
  }
589
689
  }
590
690
 
@@ -596,6 +696,10 @@ export class FeishuHarnessBridge {
596
696
  this.#groupTopicReply = value === true;
597
697
  }
598
698
 
699
+ setStepPush(value) {
700
+ this.#stepPush = value === true;
701
+ }
702
+
599
703
  #isAddressed(event) {
600
704
  if (event?.message?.chat_type === 'p2p') return true;
601
705
  const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
@@ -631,10 +735,13 @@ export class FeishuHarnessBridge {
631
735
  /**
632
736
  * Record whether replies anchored on `messageId` should stay in a topic.
633
737
  * `asRoot` marks a main-feed question this bot will auto-open as a topic.
738
+ * The flag is keyed on the conversation shape alone: once a conversation is
739
+ * happening inside a topic, its replies belong to that topic whether the
740
+ * bot auto-opens topics (群话题回复) or the user maintains them manually.
634
741
  */
635
742
  #rememberTopicReply(messageId, key) {
636
743
  if (!nonEmptyString(messageId)) return;
637
- this.#anchorTopicReply.set(messageId, this.#groupTopicReply && isTopicGroupKey(key));
744
+ this.#anchorTopicReply.set(messageId, isTopicGroupKey(key));
638
745
  if (this.#anchorTopicReply.size > 2048) {
639
746
  const oldest = this.#anchorTopicReply.keys().next().value;
640
747
  if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
@@ -647,8 +754,10 @@ export class FeishuHarnessBridge {
647
754
 
648
755
  /** Whether a reply anchored on `replyTo` must ask Feishu to keep it in a topic. */
649
756
  #replyInThreadFor(replyTo) {
650
- return this.#groupTopicReply
651
- && nonEmptyString(replyTo)
757
+ // Topic membership is decided by the conversation shape alone (see
758
+ // #rememberTopicReply): a manual-topic chat threads its replies even
759
+ // when the 群话题回复 switch is off.
760
+ return nonEmptyString(replyTo)
652
761
  && this.#anchorTopicReply.get(replyTo) === true;
653
762
  }
654
763
 
@@ -1128,6 +1237,7 @@ export class FeishuHarnessBridge {
1128
1237
  // Drain to a fixed point: awaited work can register compensation or
1129
1238
  // another serialized tail before it settles.
1130
1239
  for (;;) {
1240
+ await this.#deferred.whenIdle();
1131
1241
  const tasks = [
1132
1242
  ...this.#queues.values(),
1133
1243
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -1141,6 +1251,7 @@ export class FeishuHarnessBridge {
1141
1251
  ];
1142
1252
  if (tasks.length === 0) return;
1143
1253
  await Promise.allSettled(tasks);
1254
+ await this.#deferred.whenIdle();
1144
1255
  if (this.#queues.size === 0
1145
1256
  && this.#interactionTasks.size === 0
1146
1257
  && this.#commandTasks.size === 0
@@ -1166,7 +1277,7 @@ export class FeishuHarnessBridge {
1166
1277
  await this.#state.markSeen(messageId);
1167
1278
  this.#status.lastMessageAt = new Date().toISOString();
1168
1279
  this.#status.messagesReceived += 1;
1169
- const result = await runner(
1280
+ let result = await runner(
1170
1281
  nonEmptyString(message.content) ?? '',
1171
1282
  this.#harness,
1172
1283
  this.#state,
@@ -1175,6 +1286,7 @@ export class FeishuHarnessBridge {
1175
1286
  signal: this.#signal,
1176
1287
  isDirect: event.message.chat_type === 'p2p',
1177
1288
  hasImages: hasInboundImages(message),
1289
+ deferredDelivery: this.#deferred,
1178
1290
  hasFiles: hasInboundFiles(message),
1179
1291
  pendingInteraction: this.#hasPendingInteraction(key),
1180
1292
  control: { owner: this, key },
@@ -2831,7 +2943,7 @@ export class FeishuHarnessBridge {
2831
2943
  */
2832
2944
  async #handleStop(key, chatId, replyTo = null) {
2833
2945
  try {
2834
- const result = await runControlCommand(
2946
+ let result = await runControlCommand(
2835
2947
  '/stop', this.#harness, this.#state, key, {
2836
2948
  signal: this.#signal,
2837
2949
  control: { owner: this, key },
@@ -2952,6 +3064,61 @@ export class FeishuHarnessBridge {
2952
3064
 
2953
3065
  // ── Watches: read-only session tracking + completion pushes ─────────────
2954
3066
 
3067
+ // ── Deferred delivery (MODEL_REPLY_TIMEOUT → late result push) ──────────
3068
+
3069
+ async #deliverDeferredOutcome(entry, outcome) {
3070
+ this.#rememberTopicReply(entry.replyToMessageId, entry.key);
3071
+ if (entry.key === managedGroupKey(entry.chatId, entry.replyToMessageId)) {
3072
+ this.#rememberTopicRoot(entry.replyToMessageId);
3073
+ }
3074
+ const text = deferredOutcomeText(outcome);
3075
+ if (outcome.found) {
3076
+ return this.#deliverDeferredText(entry.chatId, answerTextForDelivery(text, []), entry.replyToMessageId);
3077
+ }
3078
+ await this.#send(entry.chatId, text, { replyTo: entry.replyToMessageId });
3079
+ return true;
3080
+ }
3081
+
3082
+ async #deliverDeferredText(chatId, markdownText, replyToMessageId) {
3083
+ if (this.#channel?.stream) {
3084
+ try {
3085
+ await this.#channel.stream(chatId, {
3086
+ markdown: async (controller) => {
3087
+ await controller.setContent(markdownText);
3088
+ },
3089
+ }, {
3090
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
3091
+ ...(this.#replyInThreadFor(replyToMessageId) ? {
3092
+ replyInThread: true,
3093
+ onReplyThreadId: async (threadId) => this.#registerTopicThreadId(
3094
+ threadId,
3095
+ replyToMessageId,
3096
+ chatId,
3097
+ ),
3098
+ } : {}),
3099
+ });
3100
+ return true;
3101
+ } catch (error) {
3102
+ if (error?.deliveryOutcome === 'unknown') throw error;
3103
+ this.#logger.warn?.(
3104
+ '[dsh-feishu] deferred stream delivery failed; falling back to text:',
3105
+ error.message,
3106
+ );
3107
+ }
3108
+ }
3109
+ try {
3110
+ await this.#sendAnswerText(chatId, markdownText, {
3111
+ deliveryId: `deferred-${Date.now()}`,
3112
+ presentation: 'feishu-deferred-text',
3113
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
3114
+ });
3115
+ return true;
3116
+ } catch (error) {
3117
+ this.#logger.warn?.('[dsh-feishu] deferred text delivery failed:', error.message);
3118
+ throw error;
3119
+ }
3120
+ }
3121
+
2955
3122
  #ensureEventWatcher() {
2956
3123
  if (this.#eventWatcher) return;
2957
3124
  if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
@@ -2963,6 +3130,7 @@ export class FeishuHarnessBridge {
2963
3130
  onSessionEvent: (payload) => this.#onHarnessEvent(payload),
2964
3131
  onReconnect: () => {
2965
3132
  void this.#compensateMissedEvents();
3133
+ void this.#deferred.resume();
2966
3134
  },
2967
3135
  });
2968
3136
  Promise.resolve(this.#eventWatcher).catch((error) => {
@@ -3321,6 +3489,7 @@ export class FeishuHarnessBridge {
3321
3489
  .some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
3322
3490
  if (needsBaseline || hasFailedDelivery) await this.#compensateSession(sessionId);
3323
3491
  await this.#deliverCompletion(sessionId, event);
3492
+ await this.#deferred.onEvent({ sessionId, event });
3324
3493
  });
3325
3494
  }
3326
3495
 
@@ -3535,6 +3704,405 @@ export class FeishuHarnessBridge {
3535
3704
  };
3536
3705
  }
3537
3706
 
3707
+ // ── Step push (分步直推) ──────────────────────────────────────────────────
3708
+
3709
+ /** One-line intent summary built from a tool call's JSON arguments. */
3710
+ #stepIntentSummary(argumentsText) {
3711
+ if (typeof argumentsText !== 'string' || !argumentsText.trim()) return null;
3712
+ let source = null;
3713
+ try {
3714
+ const parsed = JSON.parse(argumentsText);
3715
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
3716
+ for (const field of STEP_PUSH_INTENT_FIELDS) {
3717
+ const value = parsed[field];
3718
+ if (typeof value === 'string' && value.trim()) {
3719
+ source = value;
3720
+ break;
3721
+ }
3722
+ if (typeof value === 'number' && Number.isFinite(value)) {
3723
+ source = String(value);
3724
+ break;
3725
+ }
3726
+ }
3727
+ }
3728
+ } catch {
3729
+ source = argumentsText;
3730
+ }
3731
+ if (source === null) return null;
3732
+ return stepPushLine(source).slice(0, STEP_PUSH_SUMMARY_MAX_CHARS) || null;
3733
+ }
3734
+
3735
+ /** Render one tool call as a rich-text post: headline + folded code block. */
3736
+ #formatStepToolPost(update) {
3737
+ const summary = this.#stepIntentSummary(update.arguments);
3738
+ const name = stepPushLine(update.name) || t('工具');
3739
+ const headline = `✅ ${name}${summary ? ` — ${summary}` : ''}`;
3740
+ const detail = typeof update.arguments === 'string' && update.arguments
3741
+ ? update.arguments.slice(0, STEP_PUSH_ARGUMENTS_MAX_CHARS)
3742
+ : '';
3743
+ const paragraphs = [[{ tag: 'text', text: headline }]];
3744
+ if (detail) {
3745
+ const language = /^\s*[[{]/.test(detail) ? 'JSON' : undefined;
3746
+ paragraphs.push(language
3747
+ ? [{ tag: 'code_block', language, text: detail }]
3748
+ : [{ tag: 'code_block', text: detail }]);
3749
+ }
3750
+ return {
3751
+ paragraphs,
3752
+ fallbackText: detail ? `${headline}\n▸ ${detail}` : headline,
3753
+ };
3754
+ }
3755
+
3756
+ /**
3757
+ * Push one step message through the conversation's serial step queue: the
3758
+ * 250ms minimum interval guards Feishu's 5 QPS per-chat limit, and a turn
3759
+ * past the message cap stays silent (circuit breaker) while the final
3760
+ * answer still follows. Send failures are logged, never thrown — a flaky
3761
+ * step message must never break the turn.
3762
+ */
3763
+ async #sendStepMessage(chatId, key, paragraphs, fallbackText, replyToMessageId = null, { billable = true } = {}) {
3764
+ const state = this.#stepPushSendState.get(key)
3765
+ ?? { lastSentAt: 0, count: 0, breakerLogged: false };
3766
+ this.#stepPushSendState.set(key, state);
3767
+ if (billable && state.count >= STEP_PUSH_MAX_MESSAGES_PER_TURN) {
3768
+ if (!state.breakerLogged) {
3769
+ state.breakerLogged = true;
3770
+ this.#logger.warn?.(
3771
+ `[dsh-feishu] step push hit ${STEP_PUSH_MAX_MESSAGES_PER_TURN} messages for this turn; staying silent until it ends`,
3772
+ );
3773
+ }
3774
+ return;
3775
+ }
3776
+ this.#signal?.throwIfAborted();
3777
+ const wait = state.lastSentAt + STEP_PUSH_MIN_INTERVAL_MS - this.#stepPushClock.now();
3778
+ if (wait > 0) await this.#stepPushClock.delay(wait);
3779
+ // /stop or shutdown during the wait window must not let the queued
3780
+ // message land after the teardown has begun.
3781
+ this.#signal?.throwIfAborted();
3782
+ state.lastSentAt = this.#stepPushClock.now();
3783
+ if (billable) state.count += 1;
3784
+ this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
3785
+ await this.#sendStepPost(chatId, replyToMessageId, paragraphs, fallbackText);
3786
+ }
3787
+
3788
+ /**
3789
+ * Rich-text (post) delivery for step messages. Degradation ladder:
3790
+ * post(限流重试 ×2)→ 同话题纯文本回复 → 主界面纯文本。Returns
3791
+ * `{ ok, messageId?, error? }` — callers decide whether a failure is
3792
+ * tolerable (process notes: log and continue) or fatal (final answer:
3793
+ * surface it).
3794
+ */
3795
+ async #sendStepPost(chatId, replyToMessageId, paragraphs, fallbackText) {
3796
+ const content = JSON.stringify({ zh_cn: { content: paragraphs } });
3797
+ if (!replyToMessageId) {
3798
+ const sentId = await this.#send(chatId, fallbackText);
3799
+ return { ok: true, messageId: typeof sentId === 'string' ? sentId : null };
3800
+ }
3801
+ const replyInThread = this.#replyInThreadFor(replyToMessageId);
3802
+ let lastError = null;
3803
+ // 1) 富文本 post;限流/瞬时失败重试两次(结构化 code 感知)。
3804
+ for (let attempt = 0; attempt < 3; attempt += 1) {
3805
+ try {
3806
+ const response = await this.#replyStepMessage(
3807
+ chatId, replyToMessageId, 'post', content, replyInThread,
3808
+ );
3809
+ return {
3810
+ ok: true,
3811
+ messageId: nonEmptyString(response?.data?.message_id) ?? null,
3812
+ };
3813
+ } catch (error) {
3814
+ lastError = error;
3815
+ if (attempt < 2 && this.#isRetriableSendError(error)) {
3816
+ await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)));
3817
+ continue;
3818
+ }
3819
+ break;
3820
+ }
3821
+ }
3822
+ // 2) 明确降级:仅富文本格式被拒时,话题内纯文本回复仍然可用——
3823
+ // 优先留在话题内,而不是直接漏到主界面。
3824
+ try {
3825
+ const response = await this.#replyStepMessage(
3826
+ chatId, replyToMessageId, 'text', JSON.stringify({ text: fallbackText }), replyInThread,
3827
+ );
3828
+ return {
3829
+ ok: true,
3830
+ messageId: nonEmptyString(response?.data?.message_id) ?? null,
3831
+ };
3832
+ } catch (error) {
3833
+ lastError = lastError ?? error;
3834
+ }
3835
+ // 3) 最后兜底:主界面纯文本。
3836
+ this.#logger.warn?.(
3837
+ '[dsh-feishu] step push post failed; falling back to main chat text:',
3838
+ lastError?.message ?? String(lastError),
3839
+ );
3840
+ try {
3841
+ const sentId = await this.#send(chatId, fallbackText);
3842
+ return { ok: true, messageId: typeof sentId === 'string' ? sentId : null };
3843
+ } catch (fallbackError) {
3844
+ this.#logger.warn?.(
3845
+ '[dsh-feishu] step push send failed:',
3846
+ fallbackError?.message ?? String(fallbackError),
3847
+ );
3848
+ return { ok: false, error: lastError ?? fallbackError };
3849
+ }
3850
+ }
3851
+
3852
+ /** Reply with one msg_type; throws errors carrying the structured Feishu code. */
3853
+ async #replyStepMessage(chatId, messageId, msgType, contentString, replyInThread) {
3854
+ const response = await this.#client.im.v1.message.reply({
3855
+ path: { message_id: messageId },
3856
+ data: {
3857
+ msg_type: msgType,
3858
+ content: contentString,
3859
+ ...(replyInThread ? { reply_in_thread: true } : {}),
3860
+ },
3861
+ });
3862
+ if (response?.code && response.code !== 0) {
3863
+ const error = new Error(`Feishu reply failed: ${response.msg || response.code}`);
3864
+ // Preserve the structured code: rate-limit detection must not depend on
3865
+ // the human-readable message surviving.
3866
+ error.code = response.code;
3867
+ error.feishuCode = response.code;
3868
+ throw error;
3869
+ }
3870
+ const threadId = nonEmptyString(response?.data?.thread_id);
3871
+ if (threadId) {
3872
+ await this.#registerTopicThreadId(threadId, messageId, chatId);
3873
+ }
3874
+ return response;
3875
+ }
3876
+
3877
+ /** Rate-limit / transient send errors are worth one more attempt. */
3878
+ #isRetriableSendError(error) {
3879
+ const structured = [error?.code, error?.feishuCode, error?.response?.data?.code]
3880
+ .map((value) => String(value ?? ''));
3881
+ return structured.some((code) => code === '230020' || code === '230006')
3882
+ || /230020|230006/.test(error?.message ?? '');
3883
+ }
3884
+
3885
+ /**
3886
+ * 分步直推:`#answerWithStream` 流式分支在开关开启时的完整替代路径。
3887
+ * 过程(工具调用、助手中间说明)与最终答案均以富文本 post 逐条直推
3888
+ * (工具参数折叠为代码块);post 失败走既有纯文本降级。
3889
+ */
3890
+ async #answerWithStepPush(event, key, message, { onAskComplete } = {}) {
3891
+ const chatId = event.message.chat_id;
3892
+ const messageId = event.message.message_id;
3893
+ const text = message.content;
3894
+ let askCompleted = false;
3895
+ const markAskComplete = () => {
3896
+ if (askCompleted) return;
3897
+ askCompleted = true;
3898
+ onAskComplete?.();
3899
+ };
3900
+ // 与流式分支一致的提示内容构造:图片与回复引用展开为富提示内容,已接受
3901
+ // 的上下文增强按原样重放。直推分流发生在 `#answerWithStream` 构造之前,
3902
+ // 这里就是本回合唯一一次构造(无重复的 prompt 往返)。
3903
+ let content = hasInboundImages(message) || hasReplyReference(message)
3904
+ ? await promptContentForInboundMessage(message, { signal: this.#signal })
3905
+ : undefined;
3906
+ const snapshot = this.#acceptedMessageIds.get(messageId);
3907
+ let contextEnhanced = false;
3908
+ if (snapshot) {
3909
+ const originalContent = content ?? text;
3910
+ content = enhanceContextContent(originalContent, snapshot, () => ({
3911
+ channel: 'feishu',
3912
+ senderId: senderOpenId(event),
3913
+ chatId: event.message.chat_id,
3914
+ threadId: event.message.thread_id,
3915
+ }));
3916
+ contextEnhanced = content !== originalContent;
3917
+ }
3918
+ // Every turn reopens the per-conversation circuit breaker.
3919
+ const priorState = this.#stepPushSendState.get(key);
3920
+ if (this.#stepPushSendState.size >= MAX_STEP_PUSH_SEND_STATES && !priorState) {
3921
+ const oldest = this.#stepPushSendState.keys().next().value;
3922
+ if (oldest !== undefined) this.#stepPushSendState.delete(oldest);
3923
+ }
3924
+ this.#stepPushSendState.set(key, {
3925
+ lastSentAt: priorState?.lastSentAt ?? 0,
3926
+ count: 0,
3927
+ breakerLogged: false,
3928
+ });
3929
+
3930
+ // 上下文注入动作在详细级别下也体现为一条步骤(注入细节可忽略)。
3931
+ // 不计入工具/助手消息的熔断计数(规格口径:熔断只管「工具 + 助手」)。
3932
+ if (contextEnhanced || content) {
3933
+ await this.#sendStepMessage(
3934
+ chatId, key,
3935
+ [[{ tag: 'text', text: t('📎 已注入会话上下文') }]],
3936
+ t('📎 已注入会话上下文'),
3937
+ messageId,
3938
+ { billable: false },
3939
+ );
3940
+ }
3941
+
3942
+ // 缓冲-确认策略:每步定稿的助手文本先进缓冲;工具调用或更大 step 的定稿
3943
+ // 证明它是过程说明时才 flush 为 💬 消息。turn 结束时缓冲内剩余的即最终步
3944
+ // ——不直推,改为最终答案的富文本 post 内容,避免同一句话重复出现。
3945
+ let pendingStep = null;
3946
+ const flushPendingStep = async () => {
3947
+ if (!pendingStep) return;
3948
+ const note = pendingStep;
3949
+ pendingStep = null;
3950
+ await this.#sendStepMessage(
3951
+ chatId, key,
3952
+ [[{ tag: 'md', text: `💬 ${note.text}` }]],
3953
+ `💬 ${note.text}`,
3954
+ messageId,
3955
+ );
3956
+ };
3957
+ const completed = await askInWorkspaceSession({
3958
+ deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3959
+ harness: this.#harness,
3960
+ state: this.#state,
3961
+ key,
3962
+ text,
3963
+ content,
3964
+ contextEnhanced,
3965
+ createOptions: { signal: this.#signal },
3966
+ existsOptions: { signal: this.#signal },
3967
+ askOptions: {
3968
+ ...this.#interactionAskOptions(event, key, message.files),
3969
+ progressMode: 'all',
3970
+ onUpdate: async (update) => {
3971
+ if (update.type === 'assistant-message') {
3972
+ if (pendingStep && Number(update.step) > Number(pendingStep.step)) {
3973
+ await flushPendingStep();
3974
+ }
3975
+ pendingStep = update;
3976
+ return;
3977
+ }
3978
+ if (update.type === 'tool') {
3979
+ await flushPendingStep();
3980
+ const { paragraphs, fallbackText } = this.#formatStepToolPost(update);
3981
+ await this.#sendStepMessage(chatId, key, paragraphs, fallbackText, messageId);
3982
+ return;
3983
+ }
3984
+ if (update.type === 'status' && update.error) {
3985
+ const name = stepPushLine(update.toolName) || t('工具');
3986
+ const excerpt = stepPushLine(update.error).slice(0, STEP_PUSH_ERROR_MAX_CHARS);
3987
+ await this.#sendStepMessage(
3988
+ chatId, key,
3989
+ [[{ tag: 'md', text: `⚠️ **${name}** — ${excerpt}` }]],
3990
+ `⚠️ ${name} — ${excerpt}`,
3991
+ messageId,
3992
+ );
3993
+ }
3994
+ // text / status(无错误)保持静默:详细级直推完全取代简略级进度行。
3995
+ },
3996
+ },
3997
+ });
3998
+ markAskComplete();
3999
+ const finalStepText = pendingStep ? pendingStep.text : null;
4000
+ pendingStep = null;
4001
+ const finalText = (typeof finalStepText === 'string' && finalStepText.trim())
4002
+ ? finalStepText
4003
+ : completed.answer;
4004
+ const deliveryText = answerTextForDelivery(finalText, completed.artifacts ?? []);
4005
+ let textReceipt;
4006
+ let postDeliveryError = null;
4007
+ try {
4008
+ // 分步直推模式下最终答案以富文本 post(md 标签,GFM 全语法)推送;
4009
+ // 超长答案按段落边界切分为多条 post(单条 content 上限 30KB)。
4010
+ // 任一分段失败都视为最终答案失败——进入 catch 阶梯上抛,
4011
+ // 决不允许「用户没收到答案、状态却记为已回复」。
4012
+ const providerMessageIds = [];
4013
+ const chunks = splitStepPostMarkdown(deliveryText);
4014
+ let failedChunkIndex = -1;
4015
+ for (let index = 0; index < chunks.length; index += 1) {
4016
+ const chunk = chunks[index];
4017
+ const result = await this.#sendStepPost(
4018
+ chatId,
4019
+ messageId,
4020
+ [[{ tag: 'md', text: chunk }]],
4021
+ chunk,
4022
+ );
4023
+ if (!result.ok) {
4024
+ postDeliveryError ??= new Error(
4025
+ result.error?.message ?? 'step push post delivery failed',
4026
+ { cause: result.error },
4027
+ );
4028
+ failedChunkIndex = index;
4029
+ break;
4030
+ }
4031
+ if (result.messageId) providerMessageIds.push(result.messageId);
4032
+ }
4033
+ if (postDeliveryError) {
4034
+ postDeliveryError.failedChunkIndex = failedChunkIndex;
4035
+ postDeliveryError.providerMessageIds = providerMessageIds;
4036
+ postDeliveryError.remainingChunks = chunks.slice(failedChunkIndex);
4037
+ throw postDeliveryError;
4038
+ }
4039
+ textReceipt = createDeliveryReceipt({
4040
+ deliveryId: messageId,
4041
+ presentation: 'feishu-step-push-post',
4042
+ providerMessageIds,
4043
+ });
4044
+ this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
4045
+ } catch (error) {
4046
+ this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
4047
+ this.#logger.warn?.('[dsh-feishu] step push post failed; sending final text:', error.message);
4048
+ let textSendError = null;
4049
+ const providerMessageIds = Array.isArray(error?.providerMessageIds)
4050
+ ? [...error.providerMessageIds]
4051
+ : [];
4052
+ const remainingChunks = Array.isArray(error?.remainingChunks)
4053
+ ? error.remainingChunks
4054
+ : [deliveryText];
4055
+ for (const chunk of remainingChunks) {
4056
+ try {
4057
+ const receipt = await this.#sendAnswerText(chatId, chunk, {
4058
+ deliveryId: messageId,
4059
+ presentation: 'feishu-step-push-text',
4060
+ replyTo: messageId,
4061
+ });
4062
+ providerMessageIds.push(...receipt.providerMessageIds);
4063
+ } catch (fallbackError) {
4064
+ textSendError = channelDeliveryFailure(fallbackError);
4065
+ this.#logger.warn?.(
4066
+ '[dsh-feishu] fallback text delivery failed; continuing with result files:',
4067
+ fallbackError,
4068
+ );
4069
+ break;
4070
+ }
4071
+ }
4072
+ textReceipt = providerMessageIds.length > 0
4073
+ ? createDeliveryReceipt({
4074
+ deliveryId: messageId,
4075
+ presentation: 'feishu-step-push-post-and-text',
4076
+ providerMessageIds,
4077
+ })
4078
+ : undefined;
4079
+ const delivery = await this.#deliverArtifacts(
4080
+ chatId,
4081
+ messageId,
4082
+ completed.artifacts ?? [],
4083
+ textReceipt,
4084
+ );
4085
+ const artifactDispatched = delivery.receipt.artifacts.some(
4086
+ ({ outcome }) => outcome === 'sent' || outcome === 'unknown',
4087
+ );
4088
+ if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
4089
+ throw textSendError;
4090
+ }
4091
+ if (textSendError && delivery.artifactSendErrors === 0) {
4092
+ setLastMessageFailure(this.#status, textSendError);
4093
+ }
4094
+ this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
4095
+ return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
4096
+ }
4097
+ const delivery = await this.#deliverArtifacts(
4098
+ chatId,
4099
+ messageId,
4100
+ completed.artifacts ?? [],
4101
+ textReceipt,
4102
+ );
4103
+ return { ...delivery, textDeliveryErrors: 0 };
4104
+ }
4105
+
3538
4106
  async #answerWithStream(event, key, message, { onAskComplete } = {}) {
3539
4107
  const chatId = event.message.chat_id;
3540
4108
  const messageId = event.message.message_id;
@@ -3545,6 +4113,13 @@ export class FeishuHarnessBridge {
3545
4113
  askCompleted = true;
3546
4114
  onAskComplete?.();
3547
4115
  };
4116
+ // 分步直推:开关开启且通道支持流式卡时,在构造提示内容之前分流到完整替
4117
+ // 代路径(`#answerWithStepPush` 自行构造一次,回复引用回合不做第二次
4118
+ // promptContentForInboundMessage 往返);关闭或无流式卡通道时与 main
4119
+ // 零差异(含下方 `!this.#channel?.stream` 纯文本路径)。
4120
+ if (this.#stepPush && this.#channel?.stream) {
4121
+ return this.#answerWithStepPush(event, key, message, { onAskComplete });
4122
+ }
3548
4123
  let content = hasInboundImages(message) || hasReplyReference(message)
3549
4124
  ? await promptContentForInboundMessage(message, { signal: this.#signal })
3550
4125
  : undefined;
@@ -3562,6 +4137,7 @@ export class FeishuHarnessBridge {
3562
4137
  }
3563
4138
  if (!this.#channel?.stream) {
3564
4139
  const { answer, artifacts = [] } = await askInWorkspaceSession({
4140
+ deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3565
4141
  harness: this.#harness,
3566
4142
  state: this.#state,
3567
4143
  key,
@@ -3632,6 +4208,7 @@ export class FeishuHarnessBridge {
3632
4208
  },
3633
4209
  };
3634
4210
  const completed = await askInWorkspaceSession({
4211
+ deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3635
4212
  harness: this.#harness,
3636
4213
  state: this.#state,
3637
4214
  key,
@@ -3701,6 +4278,7 @@ export class FeishuHarnessBridge {
3701
4278
 
3702
4279
  this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
3703
4280
  const { answer, artifacts = [] } = await askInWorkspaceSession({
4281
+ deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3704
4282
  harness: this.#harness,
3705
4283
  state: this.#state,
3706
4284
  key,