@qwen-code/channel-dingtalk 0.23.1-preview.1 â 0.23.1-preview.2
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/dist/DingtalkAdapter.d.ts +43 -3
- package/dist/DingtalkAdapter.d.ts.map +1 -1
- package/dist/DingtalkAdapter.js +766 -16
- package/dist/DingtalkAdapter.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/DingtalkAdapter.js
CHANGED
|
@@ -408,6 +408,15 @@ function formatChatRecord(content, budget = MAX_CHAT_RECORD_CHARS) {
|
|
|
408
408
|
}
|
|
409
409
|
/** Track seen msgIds to deduplicate retried callbacks. */
|
|
410
410
|
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
411
|
+
const BACKGROUND_RESPONSE_AGGREGATION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
412
|
+
/**
|
|
413
|
+
* A failed aggregation delivery is retried rather than dropped: aggregating
|
|
414
|
+
* concentrates a whole turn into one send, and the burst that makes a send
|
|
415
|
+
* fail (several agents finishing at once against one chat quota) is exactly
|
|
416
|
+
* when the whole result would be lost.
|
|
417
|
+
*/
|
|
418
|
+
const BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS = 30 * 1000;
|
|
419
|
+
const BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES = 3;
|
|
411
420
|
const ACK_REACTION_NAME = 'đ';
|
|
412
421
|
const ACK_EMOTION_ID = '2659900';
|
|
413
422
|
const ACK_EMOTION_BG_ID = 'im_bg_1';
|
|
@@ -595,6 +604,24 @@ async function withConnectLoggingSuppressed(connect) {
|
|
|
595
604
|
}
|
|
596
605
|
}
|
|
597
606
|
}
|
|
607
|
+
class ProactiveTextDeliveryError extends Error {
|
|
608
|
+
plan;
|
|
609
|
+
retryable;
|
|
610
|
+
constructor(plan, cause) {
|
|
611
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
612
|
+
this.plan = plan;
|
|
613
|
+
if (cause instanceof DingtalkCardRequestError) {
|
|
614
|
+
this.retryable = cause.retryable;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
class ReplyTextDeliveryError extends Error {
|
|
619
|
+
plan;
|
|
620
|
+
constructor(plan, cause) {
|
|
621
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
622
|
+
this.plan = plan;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
598
625
|
export class DingtalkChannel extends ChannelBase {
|
|
599
626
|
client;
|
|
600
627
|
atSender;
|
|
@@ -622,6 +649,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
622
649
|
*/
|
|
623
650
|
proactiveToken;
|
|
624
651
|
interactiveCardConfig;
|
|
652
|
+
aggregateBackgroundAgentResponses;
|
|
625
653
|
interactiveCardClient;
|
|
626
654
|
statusCardController;
|
|
627
655
|
questionCardController;
|
|
@@ -641,6 +669,10 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
641
669
|
// must be dropped, because recreating state would post the tail of a
|
|
642
670
|
// force-split [FILE: ...] marker verbatim.
|
|
643
671
|
blockProjectionArmed = new Set();
|
|
672
|
+
backgroundResponseAggregations = new Map();
|
|
673
|
+
detachedBackgroundResponseAggregations = new Set();
|
|
674
|
+
pendingBackgroundResponseTerminals = new Map();
|
|
675
|
+
detachedPendingBackgroundResponseTerminals = new Set();
|
|
644
676
|
constructor(name, config, bridge, options) {
|
|
645
677
|
super(name, config, bridge, options);
|
|
646
678
|
this.atSender =
|
|
@@ -661,6 +693,13 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
661
693
|
this.config.instructions += FILE_INSTRUCTIONS;
|
|
662
694
|
}
|
|
663
695
|
this.interactiveCardConfig = parseDingtalkInteractiveCardConfig(config.interactiveCards);
|
|
696
|
+
const rawAggregateBackgroundAgentResponses = config.aggregateBackgroundAgentResponses;
|
|
697
|
+
if (rawAggregateBackgroundAgentResponses !== undefined &&
|
|
698
|
+
typeof rawAggregateBackgroundAgentResponses !== 'boolean') {
|
|
699
|
+
throw new Error(`Channel "${name}" aggregateBackgroundAgentResponses must be a boolean.`);
|
|
700
|
+
}
|
|
701
|
+
this.aggregateBackgroundAgentResponses =
|
|
702
|
+
rawAggregateBackgroundAgentResponses === true;
|
|
664
703
|
if (!config.clientId || !config.clientSecret) {
|
|
665
704
|
throw new Error(`Channel "${name}" requires clientId and clientSecret for DingTalk.`);
|
|
666
705
|
}
|
|
@@ -1078,11 +1117,14 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1078
1117
|
}
|
|
1079
1118
|
return replaceImageMarkers(text, markers, replacements);
|
|
1080
1119
|
}
|
|
1081
|
-
async sendReply(chatId, text, atUserId, sourceLabel, prepared = false) {
|
|
1120
|
+
async sendReply(chatId, text, atUserId, sourceLabel, prepared = false, failOnHttpError = false) {
|
|
1082
1121
|
// chatId is a conversationId â resolve to the latest sessionWebhook.
|
|
1083
1122
|
const webhook = this.webhooks.get(chatId);
|
|
1084
1123
|
if (!webhook) {
|
|
1085
1124
|
process.stderr.write(`[DingTalk:${this.name}] No webhook for chatId ${chatId}, cannot send.\n`);
|
|
1125
|
+
if (failOnHttpError) {
|
|
1126
|
+
throw new Error('DingTalk session webhook unavailable');
|
|
1127
|
+
}
|
|
1086
1128
|
return;
|
|
1087
1129
|
}
|
|
1088
1130
|
const outgoingText = prepared
|
|
@@ -1090,6 +1132,15 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1090
1132
|
: await this.prepareReplyOutput(chatId, text);
|
|
1091
1133
|
if (!outgoingText.trim())
|
|
1092
1134
|
return;
|
|
1135
|
+
const plan = this.createReplyTextDelivery(outgoingText, atUserId, sourceLabel);
|
|
1136
|
+
try {
|
|
1137
|
+
await this.deliverReplyText(chatId, plan, failOnHttpError);
|
|
1138
|
+
}
|
|
1139
|
+
catch (error) {
|
|
1140
|
+
throw new ReplyTextDeliveryError(plan, error);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
createReplyTextDelivery(outgoingText, atUserId, sourceLabel) {
|
|
1093
1144
|
const mentionPrefix = atUserId ? `@${atUserId}\n\n` : '';
|
|
1094
1145
|
const sourcePrefix = sourceLabel && outgoingText.trim().length > 0
|
|
1095
1146
|
? `${escapeDingTalkMarkdown(sourceLabel)}\n\n`
|
|
@@ -1099,17 +1150,32 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1099
1150
|
throw new Error('DingTalk source label exceeds the message limit.');
|
|
1100
1151
|
}
|
|
1101
1152
|
const chunks = normalizeDingTalkMarkdown(outgoingText, contentLimit).map((chunk, index) => `${index === 0 ? mentionPrefix : ''}${sourcePrefix}${chunk}`);
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1153
|
+
return {
|
|
1154
|
+
title: extractTitle(outgoingText),
|
|
1155
|
+
chunks,
|
|
1156
|
+
nextChunk: 0,
|
|
1157
|
+
...(atUserId ? { atUserId } : {}),
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
async deliverReplyText(chatId, plan, failOnHttpError = false) {
|
|
1161
|
+
const webhook = this.webhooks.get(chatId);
|
|
1162
|
+
if (!webhook) {
|
|
1163
|
+
if (failOnHttpError) {
|
|
1164
|
+
throw new Error('DingTalk session webhook unavailable');
|
|
1165
|
+
}
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
while (plan.nextChunk < plan.chunks.length) {
|
|
1169
|
+
const index = plan.nextChunk;
|
|
1170
|
+
const chunk = plan.chunks[index];
|
|
1171
|
+
const isMention = index === 0 && plan.atUserId !== undefined;
|
|
1106
1172
|
const body = {
|
|
1107
1173
|
msgtype: 'markdown',
|
|
1108
1174
|
markdown: {
|
|
1109
|
-
title:
|
|
1175
|
+
title: index === 0 ? plan.title : `${plan.title} (cont.)`,
|
|
1110
1176
|
text: chunk,
|
|
1111
1177
|
},
|
|
1112
|
-
...(isMention ? { at: { atUserIds: [atUserId] } } : {}),
|
|
1178
|
+
...(isMention ? { at: { atUserIds: [plan.atUserId] } } : {}),
|
|
1113
1179
|
};
|
|
1114
1180
|
let resp;
|
|
1115
1181
|
try {
|
|
@@ -1141,7 +1207,26 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1141
1207
|
if (!resp.ok) {
|
|
1142
1208
|
const detail = await resp.text().catch(() => '');
|
|
1143
1209
|
process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`);
|
|
1210
|
+
if (failOnHttpError) {
|
|
1211
|
+
throw new Error(`DingTalk reply send failed: HTTP ${resp.status} ${detail}`);
|
|
1212
|
+
}
|
|
1144
1213
|
}
|
|
1214
|
+
else if (failOnHttpError) {
|
|
1215
|
+
const payload = (await resp
|
|
1216
|
+
.clone()
|
|
1217
|
+
.json()
|
|
1218
|
+
.catch(() => undefined));
|
|
1219
|
+
const response = payload && typeof payload === 'object'
|
|
1220
|
+
? payload
|
|
1221
|
+
: undefined;
|
|
1222
|
+
const value = response?.['errcode'] ?? response?.['code'];
|
|
1223
|
+
if (value !== undefined && String(value) !== '0') {
|
|
1224
|
+
const detail = sanitizeLogText(String(response?.['errmsg'] ?? response?.['message'] ?? value), 300);
|
|
1225
|
+
process.stderr.write(`[DingTalk:${this.name}] sendMessage failed: ${detail}\n`);
|
|
1226
|
+
throw new Error(`DingTalk reply send failed: ${detail}`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
plan.nextChunk++;
|
|
1145
1230
|
}
|
|
1146
1231
|
}
|
|
1147
1232
|
async sendMessage(chatId, text) {
|
|
@@ -1177,9 +1262,20 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1177
1262
|
async pushProactive(target, text, sourceLabel) {
|
|
1178
1263
|
if (!text.trim())
|
|
1179
1264
|
return;
|
|
1265
|
+
const plan = await this.createProactiveTextDelivery(target, text, sourceLabel);
|
|
1266
|
+
if (!plan)
|
|
1267
|
+
return;
|
|
1268
|
+
try {
|
|
1269
|
+
await this.deliverProactiveText(target, plan);
|
|
1270
|
+
}
|
|
1271
|
+
catch (error) {
|
|
1272
|
+
throw new ProactiveTextDeliveryError(plan, error);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
async createProactiveTextDelivery(target, text, sourceLabel) {
|
|
1180
1276
|
const outgoingText = await this.prepareFileOutput(text, (file, mediaId) => this.sendProactiveFile(target, file, mediaId));
|
|
1181
1277
|
if (!outgoingText.trim())
|
|
1182
|
-
return;
|
|
1278
|
+
return undefined;
|
|
1183
1279
|
const sourcePrefix = sourceLabel
|
|
1184
1280
|
? `${escapeDingTalkMarkdown(sourceLabel)}\n\n`
|
|
1185
1281
|
: '';
|
|
@@ -1188,9 +1284,13 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1188
1284
|
throw new Error('DingTalk source label exceeds the message limit.');
|
|
1189
1285
|
}
|
|
1190
1286
|
const chunks = normalizeDingTalkMarkdown(outgoingText, contentLimit).map((chunk) => `${sourcePrefix}${chunk}`);
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1287
|
+
return { title: extractTitle(outgoingText), chunks, nextChunk: 0 };
|
|
1288
|
+
}
|
|
1289
|
+
async deliverProactiveText(target, plan) {
|
|
1290
|
+
while (plan.nextChunk < plan.chunks.length) {
|
|
1291
|
+
const index = plan.nextChunk;
|
|
1292
|
+
await this.sendProactiveChunk(target, index === 0 ? plan.title : `${plan.title} (cont.)`, plan.chunks[index], `chunk ${index + 1}/${plan.chunks.length}`);
|
|
1293
|
+
plan.nextChunk++;
|
|
1194
1294
|
}
|
|
1195
1295
|
}
|
|
1196
1296
|
async getProactiveToken() {
|
|
@@ -1398,6 +1498,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1398
1498
|
clearInterval(this.dedupTimer);
|
|
1399
1499
|
}
|
|
1400
1500
|
this.statusCardController?.dispose();
|
|
1501
|
+
this.drainBackgroundResponseAggregations();
|
|
1401
1502
|
this.activeReactionKeys.clear();
|
|
1402
1503
|
this.sessionReactionKeys.clear();
|
|
1403
1504
|
if (this.connectionManager) {
|
|
@@ -1495,6 +1596,11 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1495
1596
|
}
|
|
1496
1597
|
}
|
|
1497
1598
|
this.sessionMentionTargets.delete(sessionId);
|
|
1599
|
+
// A session dying after segments arrived but before the terminal signal is
|
|
1600
|
+
// precisely the case the partial-card fallback exists for; dropping the
|
|
1601
|
+
// buffer here would lose text the agent already produced, which the
|
|
1602
|
+
// pre-aggregation code always delivered on arrival.
|
|
1603
|
+
this.drainBackgroundResponseAggregations(sessionId);
|
|
1498
1604
|
const cardRunId = this.cardRunBySession.get(sessionId);
|
|
1499
1605
|
if (cardRunId) {
|
|
1500
1606
|
this.cardRunBySession.delete(sessionId);
|
|
@@ -1514,6 +1620,9 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1514
1620
|
}
|
|
1515
1621
|
super.onSessionDied(sessionId);
|
|
1516
1622
|
}
|
|
1623
|
+
onSessionRetiring(sessionId) {
|
|
1624
|
+
this.drainBackgroundResponseAggregations(sessionId);
|
|
1625
|
+
}
|
|
1517
1626
|
onTaskLifecycle(event) {
|
|
1518
1627
|
if (event.type === 'started') {
|
|
1519
1628
|
this.startReaction(event.chatId, event.messageId, event.sessionId);
|
|
@@ -1681,18 +1790,659 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1681
1790
|
process.stderr.write(`[DingTalk:${this.name}] projector tail delivery failed for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1682
1791
|
});
|
|
1683
1792
|
}
|
|
1793
|
+
/** Deliver every Agent segment immediately unless aggregation is enabled. */
|
|
1794
|
+
async dispatchBackgroundResponse(sessionId, text, context) {
|
|
1795
|
+
const target = this.router.getTarget(sessionId);
|
|
1796
|
+
if (!target ||
|
|
1797
|
+
target.channelName !== this.name ||
|
|
1798
|
+
(context !== undefined && context.kind !== 'agent')) {
|
|
1799
|
+
return super.dispatchBackgroundResponse(sessionId, text, context);
|
|
1800
|
+
}
|
|
1801
|
+
const canAggregate = this.aggregateBackgroundAgentResponses &&
|
|
1802
|
+
context?.kind === 'agent' &&
|
|
1803
|
+
typeof context.turnComplete === 'boolean';
|
|
1804
|
+
if (!canAggregate) {
|
|
1805
|
+
if (text.trim().length === 0) {
|
|
1806
|
+
return super.dispatchBackgroundResponse(sessionId, text, context);
|
|
1807
|
+
}
|
|
1808
|
+
return super.dispatchBackgroundResponse(sessionId, this.formatBackgroundAgentResponse(text, context?.label), context);
|
|
1809
|
+
}
|
|
1810
|
+
const key = JSON.stringify([sessionId, context.taskId, context.turnId]);
|
|
1811
|
+
let current = this.backgroundResponseAggregations.get(key);
|
|
1812
|
+
let parked = this.pendingBackgroundResponseTerminals.get(key);
|
|
1813
|
+
if (current?.turnComplete === true) {
|
|
1814
|
+
this.detachedBackgroundResponseAggregations.add(current);
|
|
1815
|
+
this.backgroundResponseAggregations.delete(key);
|
|
1816
|
+
current = undefined;
|
|
1817
|
+
}
|
|
1818
|
+
if (!current &&
|
|
1819
|
+
(parked?.turnEnded === true ||
|
|
1820
|
+
(parked?.turnComplete === true &&
|
|
1821
|
+
(parked.retryTimer || parked.retryInFlight || parked.resolvers > 0)))) {
|
|
1822
|
+
if (!parked.turnEnded) {
|
|
1823
|
+
this.detachedPendingBackgroundResponseTerminals.add(parked);
|
|
1824
|
+
}
|
|
1825
|
+
parked = { sessionId, target, resolvers: 0, held: [] };
|
|
1826
|
+
this.pendingBackgroundResponseTerminals.set(key, parked);
|
|
1827
|
+
}
|
|
1828
|
+
if (!current && text.trim().length === 0) {
|
|
1829
|
+
// The first segment's target resolution can suspend (named-session owner
|
|
1830
|
+
// lock), so a turn's terminal marker may arrive before the aggregation
|
|
1831
|
+
// exists. Park it instead of routing it to the empty-text early return,
|
|
1832
|
+
// or the completed turn only surfaces via the bounded wait, mislabeled.
|
|
1833
|
+
if (!parked ||
|
|
1834
|
+
(parked.resolvers === 0 &&
|
|
1835
|
+
!parked.retryTimer &&
|
|
1836
|
+
!parked.retryInFlight &&
|
|
1837
|
+
!parked.resolutionDropped &&
|
|
1838
|
+
!parked.turnComplete)) {
|
|
1839
|
+
if (parked)
|
|
1840
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
1841
|
+
return super.dispatchBackgroundResponse(sessionId, text, context);
|
|
1842
|
+
}
|
|
1843
|
+
if (context.turnComplete) {
|
|
1844
|
+
parked.turnComplete = true;
|
|
1845
|
+
parked.status = context.status;
|
|
1846
|
+
parked.label = context.label ?? parked.label;
|
|
1847
|
+
parked.completionPartial = context.partial === true;
|
|
1848
|
+
if (parked.resolvers === 0 &&
|
|
1849
|
+
!parked.retryTimer &&
|
|
1850
|
+
!parked.retryInFlight &&
|
|
1851
|
+
(parked.retryAttempts ?? 0) >=
|
|
1852
|
+
BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES) {
|
|
1853
|
+
parked.turnEnded = true;
|
|
1854
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (!current) {
|
|
1860
|
+
parked ??= { sessionId, target, resolvers: 0, held: [] };
|
|
1861
|
+
this.pendingBackgroundResponseTerminals.set(key, parked);
|
|
1862
|
+
this.holdPendingBackgroundResponse(parked, text, context);
|
|
1863
|
+
parked.resolvers++;
|
|
1864
|
+
try {
|
|
1865
|
+
let delivery;
|
|
1866
|
+
try {
|
|
1867
|
+
delivery = await this.resolveBackgroundResponseDelivery(sessionId);
|
|
1868
|
+
}
|
|
1869
|
+
catch (error) {
|
|
1870
|
+
if (parked.resolvers === 1 &&
|
|
1871
|
+
!this.backgroundResponseAggregations.has(key)) {
|
|
1872
|
+
this.scheduleBackgroundResponseResolutionRetry(key, sessionId, parked);
|
|
1873
|
+
}
|
|
1874
|
+
else {
|
|
1875
|
+
const existing = this.backgroundResponseAggregations.get(key);
|
|
1876
|
+
if (existing) {
|
|
1877
|
+
if (parked.held.length > 0) {
|
|
1878
|
+
this.applyHeldBackgroundResponses(existing, parked);
|
|
1879
|
+
}
|
|
1880
|
+
this.applyPendingBackgroundResponseTerminal(existing, parked);
|
|
1881
|
+
if (parked.resolvers === 1) {
|
|
1882
|
+
if (existing.turnComplete) {
|
|
1883
|
+
await this.completeBackgroundResponseAggregation(key, existing);
|
|
1884
|
+
}
|
|
1885
|
+
else {
|
|
1886
|
+
this.scheduleBackgroundResponseAggregationFlush(key, existing);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
throw error;
|
|
1892
|
+
}
|
|
1893
|
+
if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
|
|
1894
|
+
if (parked.resolvers === 1 &&
|
|
1895
|
+
!this.backgroundResponseAggregations.has(key)) {
|
|
1896
|
+
this.scheduleBackgroundResponseResolutionRetry(key, sessionId, parked);
|
|
1897
|
+
}
|
|
1898
|
+
else {
|
|
1899
|
+
const existing = this.backgroundResponseAggregations.get(key);
|
|
1900
|
+
if (existing) {
|
|
1901
|
+
if (parked.held.length > 0) {
|
|
1902
|
+
this.applyHeldBackgroundResponses(existing, parked);
|
|
1903
|
+
}
|
|
1904
|
+
this.applyPendingBackgroundResponseTerminal(existing, parked);
|
|
1905
|
+
if (parked.resolvers === 1) {
|
|
1906
|
+
if (existing.turnComplete) {
|
|
1907
|
+
await this.completeBackgroundResponseAggregation(key, existing);
|
|
1908
|
+
}
|
|
1909
|
+
else {
|
|
1910
|
+
this.scheduleBackgroundResponseAggregationFlush(key, existing);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (parked.retiring ||
|
|
1918
|
+
this.pendingBackgroundResponseTerminals.get(key) !== parked) {
|
|
1919
|
+
await this.flushDetachedBackgroundResponse(key, sessionId, parked, delivery);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
if (parked.retryTimer) {
|
|
1923
|
+
clearTimeout(parked.retryTimer);
|
|
1924
|
+
parked.retryTimer = undefined;
|
|
1925
|
+
}
|
|
1926
|
+
current =
|
|
1927
|
+
this.backgroundResponseAggregations.get(key) ??
|
|
1928
|
+
this.createBackgroundResponseAggregation(key, sessionId, parked.held[0]?.context ?? context, delivery.target, delivery.sourceLabel);
|
|
1929
|
+
this.applyHeldBackgroundResponses(current, parked);
|
|
1930
|
+
this.applyPendingBackgroundResponseTerminal(current, parked);
|
|
1931
|
+
if (parked.resolutionDropped) {
|
|
1932
|
+
current.resolutionDropped = true;
|
|
1933
|
+
parked.resolutionDropped = undefined;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
finally {
|
|
1937
|
+
parked.resolvers--;
|
|
1938
|
+
if (this.pendingBackgroundResponseTerminals.get(key) === parked &&
|
|
1939
|
+
parked.resolvers === 0 &&
|
|
1940
|
+
!parked.retryTimer &&
|
|
1941
|
+
parked.held.length === 0 &&
|
|
1942
|
+
(!parked.resolutionDropped || parked.turnEnded)) {
|
|
1943
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
if (!current)
|
|
1947
|
+
return;
|
|
1948
|
+
if (current.turnComplete && parked.resolvers > 0)
|
|
1949
|
+
return;
|
|
1950
|
+
if (!current.turnComplete) {
|
|
1951
|
+
this.scheduleBackgroundResponseAggregationFlush(key, current);
|
|
1952
|
+
}
|
|
1953
|
+
else {
|
|
1954
|
+
await this.completeBackgroundResponseAggregation(key, current);
|
|
1955
|
+
}
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
current.status = context.status;
|
|
1959
|
+
current.label = context.label ?? current.label;
|
|
1960
|
+
if (text.trim().length > 0)
|
|
1961
|
+
current.parts.push(text);
|
|
1962
|
+
if (context.turnComplete && parked && parked.resolvers > 0) {
|
|
1963
|
+
parked.turnComplete = true;
|
|
1964
|
+
parked.status = context.status;
|
|
1965
|
+
parked.label = context.label ?? parked.label;
|
|
1966
|
+
parked.completionPartial = context.partial === true;
|
|
1967
|
+
}
|
|
1968
|
+
else if (context.turnComplete) {
|
|
1969
|
+
current.turnComplete = true;
|
|
1970
|
+
current.completionPartial = context.partial === true;
|
|
1971
|
+
}
|
|
1972
|
+
else if (parked?.turnComplete && parked.resolvers === 0) {
|
|
1973
|
+
current.turnComplete = true;
|
|
1974
|
+
current.status = parked.status ?? current.status;
|
|
1975
|
+
current.label = parked.label ?? current.label;
|
|
1976
|
+
current.completionPartial = parked.completionPartial === true;
|
|
1977
|
+
}
|
|
1978
|
+
current.resolutionDropped ||= parked?.resolutionDropped;
|
|
1979
|
+
if (!current.turnComplete) {
|
|
1980
|
+
this.scheduleBackgroundResponseAggregationFlush(key, current);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
await this.completeBackgroundResponseAggregation(key, current);
|
|
1984
|
+
}
|
|
1985
|
+
async flushBackgroundResponseAggregation(key, aggregation) {
|
|
1986
|
+
if ((this.backgroundResponseAggregations.get(key) !== aggregation &&
|
|
1987
|
+
!this.detachedBackgroundResponseAggregations.has(aggregation)) ||
|
|
1988
|
+
aggregation.flushing) {
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
this.refreshBackgroundResponseDelivery(aggregation);
|
|
1992
|
+
if (aggregation.retryTimer)
|
|
1993
|
+
clearTimeout(aggregation.retryTimer);
|
|
1994
|
+
aggregation.retryTimer = undefined;
|
|
1995
|
+
let delivery = aggregation.delivery;
|
|
1996
|
+
if (!delivery) {
|
|
1997
|
+
if (aggregation.parts.length === 0) {
|
|
1998
|
+
// A turn whose text was already drained by the bounded wait still owes
|
|
1999
|
+
// the user its completion: the last card it saw reads `ïŒéšćïŒ`.
|
|
2000
|
+
if (!this.owesTerminalBackgroundResponseCard(aggregation)) {
|
|
2001
|
+
if (aggregation.retiring || aggregation.turnComplete) {
|
|
2002
|
+
this.removeBackgroundResponseAggregation(key, aggregation);
|
|
2003
|
+
}
|
|
2004
|
+
else {
|
|
2005
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2006
|
+
}
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
if (aggregation.timeoutTimer)
|
|
2011
|
+
clearTimeout(aggregation.timeoutTimer);
|
|
2012
|
+
aggregation.timeoutTimer = undefined;
|
|
2013
|
+
const parts = aggregation.parts.splice(0);
|
|
2014
|
+
delivery = {
|
|
2015
|
+
status: aggregation.status,
|
|
2016
|
+
label: aggregation.label,
|
|
2017
|
+
parts,
|
|
2018
|
+
partial: this.isPartialBackgroundResponseDelivery(aggregation, parts.length),
|
|
2019
|
+
attempts: 0,
|
|
2020
|
+
composedTurnComplete: aggregation.turnComplete === true,
|
|
2021
|
+
};
|
|
2022
|
+
aggregation.delivery = delivery;
|
|
2023
|
+
}
|
|
2024
|
+
const body = this.formatBackgroundResponseAggregation(delivery);
|
|
2025
|
+
aggregation.flushing = true;
|
|
2026
|
+
let error;
|
|
2027
|
+
try {
|
|
2028
|
+
if (delivery.proactivePlan) {
|
|
2029
|
+
await this.deliverProactiveText(aggregation.target, delivery.proactivePlan);
|
|
2030
|
+
}
|
|
2031
|
+
else if (delivery.replyPlan) {
|
|
2032
|
+
await this.deliverReplyText(aggregation.target.chatId, delivery.replyPlan, true);
|
|
2033
|
+
}
|
|
2034
|
+
else if (this.supportsProactiveSend() &&
|
|
2035
|
+
this.supportsProactiveTarget(aggregation.target)) {
|
|
2036
|
+
await this.deliverBackgroundResponseToTarget(aggregation.sessionId, body, {
|
|
2037
|
+
target: aggregation.target,
|
|
2038
|
+
sourceLabel: aggregation.sourceLabel,
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
else {
|
|
2042
|
+
delivery.preparedReplyBody ??= await this.prepareReplyOutput(aggregation.target.chatId, body);
|
|
2043
|
+
await this.deliverBackgroundReply(aggregation.target.chatId, delivery.preparedReplyBody, aggregation.sessionId, aggregation.sourceLabel, true, true);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
catch (caught) {
|
|
2047
|
+
error = caught;
|
|
2048
|
+
if (caught instanceof ProactiveTextDeliveryError) {
|
|
2049
|
+
delivery.proactivePlan = caught.plan;
|
|
2050
|
+
}
|
|
2051
|
+
else if (caught instanceof ReplyTextDeliveryError) {
|
|
2052
|
+
delivery.replyPlan = caught.plan;
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
finally {
|
|
2056
|
+
aggregation.flushing = false;
|
|
2057
|
+
}
|
|
2058
|
+
if (error === undefined) {
|
|
2059
|
+
aggregation.delivery = undefined;
|
|
2060
|
+
aggregation.delivered = true;
|
|
2061
|
+
if (delivery.composedTurnComplete === true &&
|
|
2062
|
+
aggregation.turnComplete &&
|
|
2063
|
+
delivery.partial !== true &&
|
|
2064
|
+
!aggregation.retiring) {
|
|
2065
|
+
aggregation.completionDelivered = true;
|
|
2066
|
+
}
|
|
2067
|
+
if (aggregation.retiring || aggregation.turnComplete) {
|
|
2068
|
+
await this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2069
|
+
}
|
|
2070
|
+
else {
|
|
2071
|
+
// The turn is still open: keep the entry so its later segments re-join
|
|
2072
|
+
// this one (and stay labelled `ïŒéšćïŒ`), and re-arm the bounded wait
|
|
2073
|
+
// so a silent turn is still reaped.
|
|
2074
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2075
|
+
}
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
delivery.attempts++;
|
|
2079
|
+
process.stderr.write(`[DingTalk:${this.name}] background response delivery failed (attempt ${delivery.attempts}): ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
|
|
2080
|
+
if (delivery.attempts >= BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES ||
|
|
2081
|
+
// A permanently rejected send (an invalid appkey, a missing app) cannot
|
|
2082
|
+
// succeed later; retrying it only spends the chat's send quota.
|
|
2083
|
+
(error instanceof ProactiveTextDeliveryError && error.retryable === false)) {
|
|
2084
|
+
aggregation.delivery = undefined;
|
|
2085
|
+
aggregation.dropped = true;
|
|
2086
|
+
if (aggregation.parts.length === 0) {
|
|
2087
|
+
if (aggregation.retiring || aggregation.turnComplete) {
|
|
2088
|
+
this.removeBackgroundResponseAggregation(key, aggregation);
|
|
2089
|
+
}
|
|
2090
|
+
else {
|
|
2091
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
else if (aggregation.retiring || aggregation.turnComplete) {
|
|
2095
|
+
await this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2096
|
+
}
|
|
2097
|
+
else {
|
|
2098
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2099
|
+
}
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
aggregation.retryTimer = setTimeout(() => {
|
|
2103
|
+
aggregation.retryTimer = undefined;
|
|
2104
|
+
void this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2105
|
+
}, BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS);
|
|
2106
|
+
aggregation.retryTimer.unref?.();
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* A card carries `ïŒéšćïŒ` whenever it is not the turn's whole output: the
|
|
2110
|
+
* turn is still open, earlier text already went out (or was given up on),
|
|
2111
|
+
* or the turn itself ended early.
|
|
2112
|
+
*/
|
|
2113
|
+
isPartialBackgroundResponseDelivery(aggregation, partCount) {
|
|
2114
|
+
return (partCount > 0 &&
|
|
2115
|
+
(aggregation.delivered === true ||
|
|
2116
|
+
aggregation.dropped === true ||
|
|
2117
|
+
aggregation.resolutionDropped === true ||
|
|
2118
|
+
aggregation.retiring === true ||
|
|
2119
|
+
aggregation.completionPartial === true ||
|
|
2120
|
+
aggregation.turnComplete !== true));
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* A turn that outran the bounded wait had its text delivered under the
|
|
2124
|
+
* `ïŒéšćïŒ` label. When it then completes normally with nothing buffered,
|
|
2125
|
+
* a header-only card is the only way the chat ever learns it finished.
|
|
2126
|
+
*/
|
|
2127
|
+
owesTerminalBackgroundResponseCard(aggregation) {
|
|
2128
|
+
return (aggregation.turnComplete === true &&
|
|
2129
|
+
aggregation.delivered === true &&
|
|
2130
|
+
aggregation.completionDelivered !== true &&
|
|
2131
|
+
aggregation.retiring !== true &&
|
|
2132
|
+
aggregation.completionPartial !== true &&
|
|
2133
|
+
aggregation.dropped !== true &&
|
|
2134
|
+
aggregation.resolutionDropped !== true);
|
|
2135
|
+
}
|
|
2136
|
+
refreshBackgroundResponseDelivery(aggregation) {
|
|
2137
|
+
const delivery = aggregation.delivery;
|
|
2138
|
+
if (!delivery || aggregation.flushing)
|
|
2139
|
+
return;
|
|
2140
|
+
const plan = delivery.proactivePlan ?? delivery.replyPlan;
|
|
2141
|
+
if (plan && plan.nextChunk > 0)
|
|
2142
|
+
return;
|
|
2143
|
+
const body = this.formatBackgroundResponseAggregation(delivery);
|
|
2144
|
+
const header = body.split('\n', 1)[0];
|
|
2145
|
+
const replaceHeader = (text) => text.replace(/## (?:â
|â|âčïž) Agent · [^\n]+/, () => header);
|
|
2146
|
+
if (plan) {
|
|
2147
|
+
plan.title = extractTitle(body);
|
|
2148
|
+
plan.chunks[0] = replaceHeader(plan.chunks[0]);
|
|
2149
|
+
}
|
|
2150
|
+
if (delivery.preparedReplyBody) {
|
|
2151
|
+
delivery.preparedReplyBody = replaceHeader(delivery.preparedReplyBody);
|
|
2152
|
+
}
|
|
2153
|
+
delivery.composedTurnComplete = aggregation.turnComplete === true;
|
|
2154
|
+
}
|
|
2155
|
+
removeBackgroundResponseAggregation(key, aggregation) {
|
|
2156
|
+
if (this.backgroundResponseAggregations.get(key) === aggregation) {
|
|
2157
|
+
this.backgroundResponseAggregations.delete(key);
|
|
2158
|
+
}
|
|
2159
|
+
this.detachedBackgroundResponseAggregations.delete(aggregation);
|
|
2160
|
+
}
|
|
2161
|
+
drainBackgroundResponseAggregations(sessionId) {
|
|
2162
|
+
for (const [key, pending] of this.pendingBackgroundResponseTerminals) {
|
|
2163
|
+
if (sessionId !== undefined && pending.sessionId !== sessionId)
|
|
2164
|
+
continue;
|
|
2165
|
+
if (pending.retryTimer)
|
|
2166
|
+
clearTimeout(pending.retryTimer);
|
|
2167
|
+
pending.retryTimer = undefined;
|
|
2168
|
+
pending.retiring = true;
|
|
2169
|
+
pending.turnComplete = true;
|
|
2170
|
+
pending.completionPartial = true;
|
|
2171
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2172
|
+
this.detachedPendingBackgroundResponseTerminals.add(pending);
|
|
2173
|
+
}
|
|
2174
|
+
for (const pending of this.detachedPendingBackgroundResponseTerminals) {
|
|
2175
|
+
if (sessionId !== undefined && pending.sessionId !== sessionId)
|
|
2176
|
+
continue;
|
|
2177
|
+
if (pending.retryTimer)
|
|
2178
|
+
clearTimeout(pending.retryTimer);
|
|
2179
|
+
pending.retryTimer = undefined;
|
|
2180
|
+
pending.retiring = true;
|
|
2181
|
+
pending.turnComplete = true;
|
|
2182
|
+
pending.completionPartial = true;
|
|
2183
|
+
if (pending.held.length > 0 &&
|
|
2184
|
+
this.router.getTarget(pending.sessionId) === pending.target) {
|
|
2185
|
+
void this.flushDetachedBackgroundResponse('', pending.sessionId, pending, { target: pending.target }).catch((error) => {
|
|
2186
|
+
process.stderr.write(`[DingTalk:${this.name}] background response delivery failed during drain: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
else if (pending.held.length > 0) {
|
|
2190
|
+
process.stderr.write(`[DingTalk:${this.name}] background response target unavailable during drain; ${pending.held.length} buffered segment(s) discarded\n`);
|
|
2191
|
+
pending.held.length = 0;
|
|
2192
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2193
|
+
}
|
|
2194
|
+
else if (pending.held.length === 0) {
|
|
2195
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
const aggregations = new Set([
|
|
2199
|
+
...this.backgroundResponseAggregations.values(),
|
|
2200
|
+
...this.detachedBackgroundResponseAggregations,
|
|
2201
|
+
]);
|
|
2202
|
+
for (const aggregation of aggregations) {
|
|
2203
|
+
if (sessionId !== undefined && aggregation.sessionId !== sessionId) {
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
if (aggregation.timeoutTimer)
|
|
2207
|
+
clearTimeout(aggregation.timeoutTimer);
|
|
2208
|
+
if (aggregation.retryTimer)
|
|
2209
|
+
clearTimeout(aggregation.retryTimer);
|
|
2210
|
+
aggregation.timeoutTimer = undefined;
|
|
2211
|
+
aggregation.retryTimer = undefined;
|
|
2212
|
+
aggregation.retiring = true;
|
|
2213
|
+
aggregation.turnComplete = true;
|
|
2214
|
+
aggregation.completionPartial = true;
|
|
2215
|
+
if (!aggregation.flushing) {
|
|
2216
|
+
void this.flushBackgroundResponseAggregation(aggregation.key, aggregation);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
createBackgroundResponseAggregation(key, sessionId, context, target, sourceLabel) {
|
|
2221
|
+
const aggregation = {
|
|
2222
|
+
key,
|
|
2223
|
+
sessionId,
|
|
2224
|
+
target,
|
|
2225
|
+
sourceLabel,
|
|
2226
|
+
status: context.status,
|
|
2227
|
+
label: context.label,
|
|
2228
|
+
parts: [],
|
|
2229
|
+
};
|
|
2230
|
+
this.backgroundResponseAggregations.set(key, aggregation);
|
|
2231
|
+
return aggregation;
|
|
2232
|
+
}
|
|
2233
|
+
scheduleBackgroundResponseResolutionRetry(key, sessionId, pending) {
|
|
2234
|
+
if (pending.retiring)
|
|
2235
|
+
return;
|
|
2236
|
+
if (pending.retryTimer)
|
|
2237
|
+
return;
|
|
2238
|
+
pending.retryAttempts = (pending.retryAttempts ?? 0) + 1;
|
|
2239
|
+
if (pending.retryAttempts >= BACKGROUND_RESPONSE_AGGREGATION_MAX_RETRIES) {
|
|
2240
|
+
pending.resolutionDropped = true;
|
|
2241
|
+
pending.turnEnded = pending.turnComplete === true;
|
|
2242
|
+
pending.held.length = 0;
|
|
2243
|
+
pending.turnComplete = undefined;
|
|
2244
|
+
pending.status = undefined;
|
|
2245
|
+
pending.label = undefined;
|
|
2246
|
+
pending.completionPartial = undefined;
|
|
2247
|
+
if (pending.turnEnded) {
|
|
2248
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2249
|
+
}
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
pending.retryTimer = setTimeout(() => {
|
|
2253
|
+
pending.retryTimer = undefined;
|
|
2254
|
+
pending.retryInFlight = true;
|
|
2255
|
+
void this.retryBackgroundResponseResolution(key, sessionId, pending)
|
|
2256
|
+
.catch((error) => {
|
|
2257
|
+
process.stderr.write(`[DingTalk:${this.name}] background response target resolution failed: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
|
|
2258
|
+
})
|
|
2259
|
+
.finally(() => {
|
|
2260
|
+
pending.retryInFlight = false;
|
|
2261
|
+
if (pending.retiring ||
|
|
2262
|
+
(!pending.retryTimer && !pending.resolutionDropped)) {
|
|
2263
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2264
|
+
}
|
|
2265
|
+
if (this.pendingBackgroundResponseTerminals.get(key) === pending &&
|
|
2266
|
+
pending.resolvers === 0 &&
|
|
2267
|
+
!pending.retryTimer &&
|
|
2268
|
+
pending.held.length === 0 &&
|
|
2269
|
+
(!pending.resolutionDropped || pending.turnEnded)) {
|
|
2270
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
}, BACKGROUND_RESPONSE_AGGREGATION_RETRY_MS);
|
|
2274
|
+
pending.retryTimer.unref?.();
|
|
2275
|
+
const active = this.pendingBackgroundResponseTerminals.get(key);
|
|
2276
|
+
if (!active || active === pending) {
|
|
2277
|
+
this.pendingBackgroundResponseTerminals.set(key, pending);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
async retryBackgroundResponseResolution(key, sessionId, pending) {
|
|
2281
|
+
let delivery;
|
|
2282
|
+
try {
|
|
2283
|
+
delivery = await this.resolveBackgroundResponseDelivery(sessionId);
|
|
2284
|
+
}
|
|
2285
|
+
catch (error) {
|
|
2286
|
+
if ((this.pendingBackgroundResponseTerminals.get(key) === pending &&
|
|
2287
|
+
!this.backgroundResponseAggregations.has(key)) ||
|
|
2288
|
+
this.detachedPendingBackgroundResponseTerminals.has(pending)) {
|
|
2289
|
+
this.scheduleBackgroundResponseResolutionRetry(key, sessionId, pending);
|
|
2290
|
+
}
|
|
2291
|
+
throw error;
|
|
2292
|
+
}
|
|
2293
|
+
if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
|
|
2294
|
+
if (pending.retiring) {
|
|
2295
|
+
if (pending.held.length > 0) {
|
|
2296
|
+
process.stderr.write(`[DingTalk:${this.name}] background response target unavailable during drain; ${pending.held.length} buffered segment(s) discarded\n`);
|
|
2297
|
+
}
|
|
2298
|
+
pending.held.length = 0;
|
|
2299
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
if ((this.pendingBackgroundResponseTerminals.get(key) === pending &&
|
|
2303
|
+
!this.backgroundResponseAggregations.has(key)) ||
|
|
2304
|
+
this.detachedPendingBackgroundResponseTerminals.has(pending)) {
|
|
2305
|
+
this.scheduleBackgroundResponseResolutionRetry(key, sessionId, pending);
|
|
2306
|
+
}
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
if (pending.retiring ||
|
|
2310
|
+
this.pendingBackgroundResponseTerminals.get(key) !== pending ||
|
|
2311
|
+
pending.turnComplete) {
|
|
2312
|
+
await this.flushDetachedBackgroundResponse(key, sessionId, pending, delivery);
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
const first = pending.held[0];
|
|
2316
|
+
if (!first)
|
|
2317
|
+
return;
|
|
2318
|
+
const aggregation = this.createBackgroundResponseAggregation(key, sessionId, first.context, delivery.target, delivery.sourceLabel);
|
|
2319
|
+
this.applyHeldBackgroundResponses(aggregation, pending);
|
|
2320
|
+
if (pending.resolutionDropped) {
|
|
2321
|
+
aggregation.resolutionDropped = true;
|
|
2322
|
+
pending.resolutionDropped = undefined;
|
|
2323
|
+
}
|
|
2324
|
+
if (this.pendingBackgroundResponseTerminals.get(key) === pending) {
|
|
2325
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2326
|
+
}
|
|
2327
|
+
this.scheduleBackgroundResponseAggregationFlush(key, aggregation);
|
|
2328
|
+
}
|
|
2329
|
+
holdPendingBackgroundResponse(pending, text, context) {
|
|
2330
|
+
if (text.trim().length > 0)
|
|
2331
|
+
pending.held.push({ text, context });
|
|
2332
|
+
if (context.turnComplete) {
|
|
2333
|
+
pending.turnComplete = true;
|
|
2334
|
+
pending.status = context.status;
|
|
2335
|
+
pending.label = context.label ?? pending.label;
|
|
2336
|
+
pending.completionPartial = context.partial === true;
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
applyHeldBackgroundResponses(aggregation, pending) {
|
|
2340
|
+
for (const { text, context } of pending.held.splice(0)) {
|
|
2341
|
+
aggregation.status = context.status;
|
|
2342
|
+
aggregation.label = context.label ?? aggregation.label;
|
|
2343
|
+
aggregation.parts.push(text);
|
|
2344
|
+
if (context.turnComplete) {
|
|
2345
|
+
aggregation.turnComplete = true;
|
|
2346
|
+
aggregation.completionPartial = context.partial === true;
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
applyPendingBackgroundResponseTerminal(aggregation, pending) {
|
|
2351
|
+
if (!pending.turnComplete)
|
|
2352
|
+
return;
|
|
2353
|
+
aggregation.turnComplete = true;
|
|
2354
|
+
aggregation.status = pending.status ?? aggregation.status;
|
|
2355
|
+
aggregation.label = pending.label ?? aggregation.label;
|
|
2356
|
+
aggregation.completionPartial = pending.completionPartial === true;
|
|
2357
|
+
pending.turnComplete = undefined;
|
|
2358
|
+
pending.status = undefined;
|
|
2359
|
+
pending.label = undefined;
|
|
2360
|
+
pending.completionPartial = undefined;
|
|
2361
|
+
}
|
|
2362
|
+
async completeBackgroundResponseAggregation(key, aggregation) {
|
|
2363
|
+
if (aggregation.delivery) {
|
|
2364
|
+
aggregation.delivery.status = aggregation.status;
|
|
2365
|
+
aggregation.delivery.label =
|
|
2366
|
+
aggregation.label ?? aggregation.delivery.label;
|
|
2367
|
+
aggregation.delivery.partial =
|
|
2368
|
+
this.isPartialBackgroundResponseDelivery(aggregation, aggregation.delivery.parts.length) || aggregation.parts.length > 0;
|
|
2369
|
+
}
|
|
2370
|
+
if (aggregation.timeoutTimer)
|
|
2371
|
+
clearTimeout(aggregation.timeoutTimer);
|
|
2372
|
+
aggregation.timeoutTimer = undefined;
|
|
2373
|
+
await this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2374
|
+
}
|
|
2375
|
+
async flushDetachedBackgroundResponse(key, sessionId, pending, delivery) {
|
|
2376
|
+
const first = pending.held[0];
|
|
2377
|
+
if (!first) {
|
|
2378
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
const aggregation = {
|
|
2382
|
+
key,
|
|
2383
|
+
sessionId,
|
|
2384
|
+
target: delivery.target,
|
|
2385
|
+
sourceLabel: delivery.sourceLabel,
|
|
2386
|
+
status: first.context.status,
|
|
2387
|
+
label: first.context.label,
|
|
2388
|
+
parts: [],
|
|
2389
|
+
turnComplete: pending.turnComplete,
|
|
2390
|
+
completionPartial: pending.completionPartial,
|
|
2391
|
+
resolutionDropped: pending.resolutionDropped,
|
|
2392
|
+
};
|
|
2393
|
+
this.applyHeldBackgroundResponses(aggregation, pending);
|
|
2394
|
+
aggregation.status = pending.status ?? aggregation.status;
|
|
2395
|
+
aggregation.label = pending.label ?? aggregation.label;
|
|
2396
|
+
if (this.pendingBackgroundResponseTerminals.get(key) === pending) {
|
|
2397
|
+
this.pendingBackgroundResponseTerminals.delete(key);
|
|
2398
|
+
}
|
|
2399
|
+
this.detachedPendingBackgroundResponseTerminals.delete(pending);
|
|
2400
|
+
this.detachedBackgroundResponseAggregations.add(aggregation);
|
|
2401
|
+
await this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2402
|
+
}
|
|
2403
|
+
scheduleBackgroundResponseAggregationFlush(key, aggregation) {
|
|
2404
|
+
if (aggregation.timeoutTimer || aggregation.delivery)
|
|
2405
|
+
return;
|
|
2406
|
+
aggregation.timeoutTimer = setTimeout(() => {
|
|
2407
|
+
aggregation.timeoutTimer = undefined;
|
|
2408
|
+
void this.flushBackgroundResponseAggregation(key, aggregation);
|
|
2409
|
+
}, BACKGROUND_RESPONSE_AGGREGATION_TIMEOUT_MS);
|
|
2410
|
+
aggregation.timeoutTimer.unref?.();
|
|
2411
|
+
}
|
|
2412
|
+
formatBackgroundResponseAggregation(delivery) {
|
|
2413
|
+
const icon = delivery.status === 'completed'
|
|
2414
|
+
? 'â
'
|
|
2415
|
+
: delivery.status === 'failed'
|
|
2416
|
+
? 'â'
|
|
2417
|
+
: 'âčïž';
|
|
2418
|
+
const label = this.formatBackgroundAgentLabel(delivery.label);
|
|
2419
|
+
const header = `## ${icon} Agent · ${label}${delivery.partial ? 'ïŒéšćïŒ' : ''}`;
|
|
2420
|
+
if (delivery.parts.length === 0)
|
|
2421
|
+
return header;
|
|
2422
|
+
return `${header}\n\n${delivery.parts.join('\n\n')}`;
|
|
2423
|
+
}
|
|
2424
|
+
formatBackgroundAgentResponse(text, label) {
|
|
2425
|
+
return `## đ€ Agent · ${this.formatBackgroundAgentLabel(label)}\n\n${text}`;
|
|
2426
|
+
}
|
|
2427
|
+
formatBackgroundAgentLabel(label) {
|
|
2428
|
+
const normalized = label
|
|
2429
|
+
?.replace(/\p{Cc}+/gu, ' ')
|
|
2430
|
+
.replace(/\s+/g, ' ')
|
|
2431
|
+
.trim();
|
|
2432
|
+
return escapeDingTalkMarkdown(normalized || 'ćć°ä»»ćĄ');
|
|
2433
|
+
}
|
|
1684
2434
|
/**
|
|
1685
2435
|
* Out-of-turn one-shot sends (background responses) must not flow through
|
|
1686
2436
|
* the session's block-streaming projector: a second sender interleaving with
|
|
1687
2437
|
* mid-projection state can swallow the send or split a held marker.
|
|
1688
2438
|
*/
|
|
1689
|
-
async deliverBackgroundReply(chatId, text, sessionId, sourceLabel) {
|
|
2439
|
+
async deliverBackgroundReply(chatId, text, sessionId, sourceLabel, prepared = false, failOnHttpError = false) {
|
|
1690
2440
|
if (this.config.blockStreaming !== 'on') {
|
|
1691
|
-
return
|
|
2441
|
+
return this.sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared, failOnHttpError);
|
|
1692
2442
|
}
|
|
1693
|
-
await this.sendReply(chatId, text, undefined, sourceLabel);
|
|
2443
|
+
await this.sendReply(chatId, text, undefined, sourceLabel, prepared, failOnHttpError);
|
|
1694
2444
|
}
|
|
1695
|
-
async sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared = false) {
|
|
2445
|
+
async sendResponseMessage(chatId, text, sessionId, sourceLabel, prepared = false, failOnHttpError = false) {
|
|
1696
2446
|
let outgoingText = text;
|
|
1697
2447
|
let consumesMention = true;
|
|
1698
2448
|
if (this.config.blockStreaming === 'on') {
|
|
@@ -1709,7 +2459,7 @@ export class DingtalkChannel extends ChannelBase {
|
|
|
1709
2459
|
: undefined;
|
|
1710
2460
|
if (atUserId)
|
|
1711
2461
|
this.sessionMentionTargets.delete(sessionId);
|
|
1712
|
-
await this.sendReply(chatId, outgoingText, atUserId, sourceLabel ?? this.getResponseSourceLabel(sessionId), prepared);
|
|
2462
|
+
await this.sendReply(chatId, outgoingText, atUserId, sourceLabel ?? this.getResponseSourceLabel(sessionId), prepared, failOnHttpError);
|
|
1713
2463
|
}
|
|
1714
2464
|
projectBlockStreamChunk(text, sessionId) {
|
|
1715
2465
|
let state = this.blockFileProjectors.get(sessionId);
|