@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.
- package/PROACTIVE_DELIVERY.en.md +4 -0
- package/PROACTIVE_DELIVERY.md +4 -0
- package/README.en.md +11 -2
- package/README.md +11 -2
- package/assets/dingtalk-menu-template.json +6 -0
- package/lib/client.js +78 -2
- package/lib/index.js +273 -264
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/api.js +2 -0
- package/plugin-src/client/channels/feishu/index.js +57 -0
- package/plugin-src/client/i18n.js +5 -0
- package/plugin-src/client/styles.js +2 -1
- package/plugin-src/host/channels/feishu/production.mjs +1 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/plugin-src/host/harness-command-executor.mjs +13 -6
- package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
- package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
- package/src/channels/dingtalk/state-store.mjs +8 -0
- package/src/channels/feishu/bridge.mjs +584 -6
- package/src/channels/feishu/feishu-runtime.mjs +9 -0
- package/src/channels/feishu/multi-bot-controller.mjs +16 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/feishu/state-store.mjs +11 -0
- package/src/channels/qq/qq-bridge.mjs +121 -1
- package/src/channels/qq/qq-menu.mjs +205 -0
- package/src/channels/qq/state-store.mjs +16 -0
- package/src/channels/shared/bot-workspace-store.mjs +17 -1
- package/src/channels/shared/control-command.mjs +8 -0
- package/src/channels/shared/conversation-state-store.mjs +8 -0
- package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
- package/src/channels/shared/deferred-delivery.mjs +161 -0
- package/src/channels/shared/deferred-state.mjs +53 -0
- package/src/channels/shared/harness-client.mjs +42 -3
- package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
- package/src/channels/shared/i18n-en/feishu.mjs +8 -0
- package/src/channels/shared/i18n-en/qq.mjs +39 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/semantic/artifact.mjs +3 -1
- package/src/channels/shared/text-harness-bridge.mjs +15 -0
- package/src/channels/shared/workspace-session.mjs +16 -1
- package/src/channels/wecom/state-store.mjs +8 -0
- package/src/channels/wecom/wecom-bridge.mjs +19 -4
- package/src/channels/weixin/state-store.mjs +64 -1
- package/src/channels/weixin/weixin-bridge.mjs +31 -5
- 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(() =>
|
|
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,
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|