@xmanrui/dsh-im 4.13.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.
@@ -157,6 +157,77 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
157
157
  /** Matches fast card commands that should not be queued behind a running task. */
158
158
  const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
159
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
+
160
231
  /** Pretty-print a tool call's arguments for an approval card. */
161
232
  function operationArguments(toolCall) {
162
233
  const source = toolCall?.arguments;
@@ -247,6 +318,11 @@ function answerTextForDelivery(answer, artifacts) {
247
318
  return artifacts.length > 0 ? t('结果文件已生成。') : answer;
248
319
  }
249
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
+
250
326
  function nonEmptyString(value) {
251
327
  return typeof value === 'string' && value.trim() ? value.trim() : null;
252
328
  }
@@ -489,6 +565,12 @@ export class FeishuHarnessBridge {
489
565
  #groupResponseMode;
490
566
  /** When true, group replies that belong to a Feishu topic ask reply_in_thread. */
491
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;
492
574
  /** Group chats this bot has seen; only these may use topic replies. */
493
575
  #groupChatIds = new Set();
494
576
  /** Anchor message id → whether its replies should stay in a Feishu topic. */
@@ -533,6 +615,8 @@ export class FeishuHarnessBridge {
533
615
  botOpenId,
534
616
  groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
535
617
  groupTopicReply = false,
618
+ stepPush = false,
619
+ stepPushClock = null,
536
620
  repair,
537
621
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
538
622
  repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
@@ -560,6 +644,12 @@ export class FeishuHarnessBridge {
560
644
  || !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0) {
561
645
  throw new TypeError('Feishu timing values must be positive numbers');
562
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
+ }
563
653
  this.#client = client;
564
654
  this.#channel = channel;
565
655
  this.#harness = harness;
@@ -573,6 +663,8 @@ export class FeishuHarnessBridge {
573
663
  this.#botOpenId = nonEmptyString(botOpenId);
574
664
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
575
665
  this.#groupTopicReply = groupTopicReply === true;
666
+ this.#stepPush = stepPush === true;
667
+ this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
576
668
  this.#repair = repair ?? null;
577
669
  this.#repairPollIntervalMs = repairPollIntervalMs;
578
670
  this.#repairLinkWaitMs = repairLinkWaitMs;
@@ -604,6 +696,10 @@ export class FeishuHarnessBridge {
604
696
  this.#groupTopicReply = value === true;
605
697
  }
606
698
 
699
+ setStepPush(value) {
700
+ this.#stepPush = value === true;
701
+ }
702
+
607
703
  #isAddressed(event) {
608
704
  if (event?.message?.chat_type === 'p2p') return true;
609
705
  const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
@@ -639,10 +735,13 @@ export class FeishuHarnessBridge {
639
735
  /**
640
736
  * Record whether replies anchored on `messageId` should stay in a topic.
641
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.
642
741
  */
643
742
  #rememberTopicReply(messageId, key) {
644
743
  if (!nonEmptyString(messageId)) return;
645
- this.#anchorTopicReply.set(messageId, this.#groupTopicReply && isTopicGroupKey(key));
744
+ this.#anchorTopicReply.set(messageId, isTopicGroupKey(key));
646
745
  if (this.#anchorTopicReply.size > 2048) {
647
746
  const oldest = this.#anchorTopicReply.keys().next().value;
648
747
  if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
@@ -655,8 +754,10 @@ export class FeishuHarnessBridge {
655
754
 
656
755
  /** Whether a reply anchored on `replyTo` must ask Feishu to keep it in a topic. */
657
756
  #replyInThreadFor(replyTo) {
658
- return this.#groupTopicReply
659
- && 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)
660
761
  && this.#anchorTopicReply.get(replyTo) === true;
661
762
  }
662
763
 
@@ -3603,6 +3704,405 @@ export class FeishuHarnessBridge {
3603
3704
  };
3604
3705
  }
3605
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
+
3606
4106
  async #answerWithStream(event, key, message, { onAskComplete } = {}) {
3607
4107
  const chatId = event.message.chat_id;
3608
4108
  const messageId = event.message.message_id;
@@ -3613,6 +4113,13 @@ export class FeishuHarnessBridge {
3613
4113
  askCompleted = true;
3614
4114
  onAskComplete?.();
3615
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
+ }
3616
4123
  let content = hasInboundImages(message) || hasReplyReference(message)
3617
4124
  ? await promptContentForInboundMessage(message, { signal: this.#signal })
3618
4125
  : undefined;
@@ -111,6 +111,7 @@ export class FeishuRuntime {
111
111
  #botOpenId;
112
112
  #groupResponseMode;
113
113
  #groupTopicReply;
114
+ #stepPush;
114
115
  #ownerOpenIds;
115
116
  #harness;
116
117
  #state;
@@ -141,6 +142,7 @@ export class FeishuRuntime {
141
142
  botOpenId,
142
143
  groupResponseMode,
143
144
  groupTopicReply = false,
145
+ stepPush = false,
144
146
  ownerOpenId,
145
147
  ownerOpenIds,
146
148
  harness,
@@ -177,6 +179,7 @@ export class FeishuRuntime {
177
179
  this.#botOpenId = nonEmptyString(botOpenId);
178
180
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
179
181
  this.#groupTopicReply = groupTopicReply === true;
182
+ this.#stepPush = stepPush === true;
180
183
  this.#ownerOpenIds = normalizedOwners;
181
184
  this.#harness = harness;
182
185
  this.#state = state;
@@ -206,6 +209,11 @@ export class FeishuRuntime {
206
209
  this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
207
210
  }
208
211
 
212
+ setStepPush(value) {
213
+ this.#stepPush = value === true;
214
+ this.#bridge?.setStepPush(this.#stepPush);
215
+ }
216
+
209
217
  async start() {
210
218
  while (true) {
211
219
  while (this.#stopping) await this.#stopping;
@@ -295,6 +303,7 @@ export class FeishuRuntime {
295
303
  botOpenId: this.#botOpenId,
296
304
  groupResponseMode: this.#groupResponseMode,
297
305
  groupTopicReply: this.#groupTopicReply,
306
+ stepPush: this.#stepPush,
298
307
  repair: this.#repair,
299
308
  replyTimeoutMs: this.#replyTimeoutMs,
300
309
  // Interaction cards (approval/question buttons) are on by default.
@@ -91,6 +91,7 @@ function configuredBotFingerprint(config) {
91
91
  activated: config.activated,
92
92
  groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
93
93
  groupTopicReply: config.groupTopicReply === true,
94
+ stepPush: config.stepPush === true,
94
95
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
95
96
  deletionPending: config.deletionPending === true,
96
97
  connectedAt: config.connectedAt ?? null,
@@ -599,6 +600,20 @@ export class MultiBotDshFeishuController {
599
600
  }));
600
601
  }
601
602
 
603
+ async updateStepPush(botId, stepPush) {
604
+ this.#assertOpen();
605
+ if (typeof stepPush !== 'boolean') {
606
+ throw new TypeError('Invalid Feishu step push flag');
607
+ }
608
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
609
+ const config = this.#requireBot(botId);
610
+ const saved = await this.#configStore.saveBot({ ...config, stepPush });
611
+ this.#runtimes.get(botId)?.setStepPush?.(saved.stepPush);
612
+ this.#touch();
613
+ return this.status(botId);
614
+ }));
615
+ }
616
+
602
617
  async deleteBot(botId) {
603
618
  this.#assertOpen();
604
619
  return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
@@ -662,6 +677,7 @@ export class MultiBotDshFeishuController {
662
677
  configured: true,
663
678
  groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
664
679
  groupTopicReply: config.groupTopicReply === true,
680
+ stepPush: config.stepPush === true,
665
681
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
666
682
  bot: publicBot(config),
667
683
  connection,
@@ -46,6 +46,7 @@ function normalizeBot(value, { legacy = false } = {}) {
46
46
  activated: value.activated ?? null,
47
47
  groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
48
48
  groupTopicReply: value.groupTopicReply === true,
49
+ stepPush: value.stepPush === true,
49
50
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
50
51
  deletionPending: value.deletionPending === true,
51
52
  connectedAt: cleanString(value.connectedAt),