@qwen-code/channel-dingtalk 0.23.1-preview.0 → 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.
@@ -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
- const title = extractTitle(outgoingText);
1103
- for (let i = 0; i < chunks.length; i++) {
1104
- const chunk = chunks[i];
1105
- const isMention = i === 0 && atUserId !== undefined;
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: i === 0 ? title : `${title} (cont.)`,
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
- const title = extractTitle(outgoingText);
1192
- for (let i = 0; i < chunks.length; i++) {
1193
- await this.sendProactiveChunk(target, i === 0 ? title : `${title} (cont.)`, chunks[i], `chunk ${i + 1}/${chunks.length}`);
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 super.deliverBackgroundReply(chatId, text, sessionId, sourceLabel);
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);