@soimy/dingtalk 3.6.5 → 3.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +773 -136
  3. package/dist/index.js.map +4 -4
  4. package/dist/src/card/ask-user-question-context.d.ts +5 -2
  5. package/dist/src/card/ask-user-question-context.d.ts.map +1 -1
  6. package/dist/src/card/ask-user-question-store.d.ts +42 -0
  7. package/dist/src/card/ask-user-question-store.d.ts.map +1 -0
  8. package/dist/src/card/ask-user-question.d.ts +20 -0
  9. package/dist/src/card/ask-user-question.d.ts.map +1 -1
  10. package/dist/src/card/card-action-handler.d.ts +1 -0
  11. package/dist/src/card/card-action-handler.d.ts.map +1 -1
  12. package/dist/src/card/card-template.d.ts +1 -1
  13. package/dist/src/card/task-model-metadata.d.ts +11 -0
  14. package/dist/src/card/task-model-metadata.d.ts.map +1 -0
  15. package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
  16. package/dist/src/inbound-handler.d.ts.map +1 -1
  17. package/dist/src/reply-strategy-card.d.ts.map +1 -1
  18. package/dist/src/reply-strategy-types.d.ts +1 -0
  19. package/dist/src/reply-strategy-types.d.ts.map +1 -1
  20. package/dist/src/session-state.d.ts +11 -5
  21. package/dist/src/session-state.d.ts.map +1 -1
  22. package/dist/src/targeting/agent-routing.d.ts +8 -1
  23. package/dist/src/targeting/agent-routing.d.ts.map +1 -1
  24. package/dist/src/types.d.ts +13 -0
  25. package/dist/src/types.d.ts.map +1 -1
  26. package/docs/assets/dingtalk-ask-user-card-template.json +6 -0
  27. package/package.json +2 -1
  28. package/src/access-control.ts +1 -1
  29. package/src/card/ask-user-question-context.ts +9 -1
  30. package/src/card/ask-user-question-store.ts +294 -0
  31. package/src/card/ask-user-question.ts +380 -34
  32. package/src/card/card-action-handler.ts +2 -0
  33. package/src/card/card-template.ts +1 -1
  34. package/src/card/task-model-metadata.ts +51 -0
  35. package/src/gateway/channel-gateway.ts +19 -0
  36. package/src/inbound-handler.ts +131 -49
  37. package/src/reply-strategy-card.ts +30 -6
  38. package/src/reply-strategy-types.ts +1 -0
  39. package/src/session-state.ts +44 -13
  40. package/src/targeting/agent-routing.ts +67 -19
  41. package/src/types.ts +14 -0
@@ -13,12 +13,17 @@ import {
13
13
  isCardInTerminalState,
14
14
  recallAICardMessage,
15
15
  } from "./card-service";
16
+ import {
17
+ invalidateAskUserQuestionsForScope,
18
+ syncInvalidatedAskUserQuestionCards,
19
+ } from "./card/ask-user-question";
16
20
  import {
17
21
  getDingTalkQuestionContext,
18
22
  withDingTalkQuestionContext,
19
23
  } from "./card/ask-user-question-context";
20
24
  import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
21
25
  import { renderStatusLine } from "./card/statusline-renderer";
26
+ import { resolveConfiguredTaskModelMetadata } from "./card/task-model-metadata";
22
27
  import { dispatchDingTalkCardStopCommand } from "./command/card-stop-command";
23
28
  import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
24
29
  import {
@@ -80,7 +85,13 @@ import {
80
85
  upsertObservedUserTarget,
81
86
  } from "./targeting/target-directory-store";
82
87
  import { AICardStatus } from "./types";
83
- import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
88
+ import type {
89
+ DingTalkConfig,
90
+ HandleDingTalkMessageParams,
91
+ Logger,
92
+ MediaFile,
93
+ ResolvedDingTalkRoute,
94
+ } from "./types";
84
95
  import {
85
96
  formatDingTalkErrorPayloadLog,
86
97
  getErrorMessage,
@@ -428,6 +439,7 @@ function buildGroupTurnContextPrompt(params: {
428
439
 
429
440
  type ReplyStreamPayload = {
430
441
  text?: string;
442
+ isError?: boolean;
431
443
  isReasoning?: boolean;
432
444
  mediaUrl?: string;
433
445
  mediaUrls?: string[];
@@ -582,6 +594,8 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
582
594
  sessionWebhook,
583
595
  log,
584
596
  dingtalkConfig,
597
+ inboundOrigin = "stream",
598
+ routeOverride,
585
599
  subAgentOptions,
586
600
  preDownloadedMedia,
587
601
  } = params;
@@ -808,38 +822,42 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
808
822
  ? null
809
823
  : resolveMessageTarget({ extractedContent, cfg, isGroup });
810
824
 
811
- let route: { agentId: string; sessionKey: string; mainSessionKey: string };
812
- if (subAgentOptions) {
813
- // Recursive sub-agent dispatch (content or targeted command): route to the
814
- // agent's own session. A missing host helper throws here and is caught by
815
- // dispatchSubAgents in the parent call.
816
- route = {
817
- agentId: subAgentOptions.agentId,
818
- sessionKey: buildAgentSessionKey({
819
- rt,
820
- cfg,
821
- accountId,
822
- agentId: subAgentOptions.agentId,
823
- peerKind: sessionPeer.kind,
824
- peerId: sessionPeer.peerId,
825
- }),
826
- mainSessionKey: "",
827
- };
828
- } else {
829
- // Default route. For subagent-content / subagent-command targets the
830
- // dispatch below re-enters this function with subAgentOptions set, so this
831
- // route is only consumed when the target is "default".
832
- route = rt.channel.routing.resolveAgentRoute({
833
- cfg,
834
- channel: "dingtalk",
835
- accountId,
836
- peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
825
+ const invalidateQuestionRoutes = (routes: readonly ResolvedDingTalkRoute[]): void => {
826
+ if (inboundOrigin === "ask-user") {
827
+ return;
828
+ }
829
+ const scopeKeys = new Set(
830
+ routes.map((resolvedRoute) => `${accountId}:${resolvedRoute.sessionKey}:${senderId}`),
831
+ );
832
+ const invalidatedRecords = [];
833
+ for (const questionScopeKey of scopeKeys) {
834
+ try {
835
+ invalidatedRecords.push(
836
+ ...invalidateAskUserQuestionsForScope({
837
+ storePath: accountStorePath,
838
+ accountId,
839
+ questionScopeKey,
840
+ reason: "superseded_by_message",
841
+ log,
842
+ }),
843
+ );
844
+ } catch (err) {
845
+ log?.warn?.(
846
+ `[DingTalk][AskUser] Failed to invalidate pending cards before inbound dispatch scope=${questionScopeKey}: ${String(err)}`,
847
+ );
848
+ }
849
+ }
850
+ if (invalidatedRecords.length === 0) {
851
+ return;
852
+ }
853
+ void syncInvalidatedAskUserQuestionCards({
854
+ records: invalidatedRecords,
855
+ config: dingtalkConfig,
856
+ log,
857
+ }).catch((err) => {
858
+ log?.warn?.(`[DingTalk][AskUser] Card invalidation sync failed: ${String(err)}`);
837
859
  });
838
- }
839
- const questionContext = getDingTalkQuestionContext();
840
- if (questionContext) {
841
- questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
842
- }
860
+ };
843
861
 
844
862
  // @Sub-Agent routing: dispatch @mention-targeted messages to their agent(s).
845
863
  // Both content (`@agent <message>`) and commands (`@agent /new`) re-enter
@@ -856,6 +874,9 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
856
874
  dingtalkConfig,
857
875
  sessionWebhook,
858
876
  extractedContent,
877
+ sessionPeer,
878
+ onRoutesResolved: (targets) =>
879
+ invalidateQuestionRoutes(targets.map((target) => target.route)),
859
880
  handleMessage: handleDingTalkMessage,
860
881
  downloadMedia,
861
882
  log,
@@ -887,6 +908,9 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
887
908
  dingtalkConfig,
888
909
  sessionWebhook,
889
910
  extractedContent,
911
+ sessionPeer,
912
+ onRoutesResolved: (targets) =>
913
+ invalidateQuestionRoutes(targets.map((target) => target.route)),
890
914
  handleMessage: handleDingTalkMessage,
891
915
  downloadMedia,
892
916
  log,
@@ -896,6 +920,47 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
896
920
  // Only invalid agent names and no match: fall through to the default route.
897
921
  }
898
922
 
923
+ let route: ResolvedDingTalkRoute;
924
+ if (routeOverride) {
925
+ route = routeOverride;
926
+ } else if (subAgentOptions) {
927
+ route = {
928
+ agentId: subAgentOptions.agentId,
929
+ sessionKey: buildAgentSessionKey({
930
+ rt,
931
+ cfg,
932
+ accountId,
933
+ agentId: subAgentOptions.agentId,
934
+ peerKind: sessionPeer.kind,
935
+ peerId: sessionPeer.peerId,
936
+ }),
937
+ mainSessionKey: "",
938
+ };
939
+ } else {
940
+ route = rt.channel.routing.resolveAgentRoute({
941
+ cfg,
942
+ channel: "dingtalk",
943
+ accountId,
944
+ peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
945
+ });
946
+ }
947
+ const questionContext = getDingTalkQuestionContext();
948
+ if (questionContext) {
949
+ questionContext.resolvedRoute = route;
950
+ questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
951
+ questionContext.storePath = accountStorePath;
952
+ questionContext.continuationSubAgentOptions = subAgentOptions
953
+ ? {
954
+ agentId: subAgentOptions.agentId,
955
+ responsePrefix: subAgentOptions.responsePrefix,
956
+ matchedName: subAgentOptions.matchedName,
957
+ }
958
+ : undefined;
959
+ }
960
+ if (!subAgentOptions) {
961
+ invalidateQuestionRoutes([route]);
962
+ }
963
+
899
964
  // Route resolved before media download for session context and routing metadata.
900
965
  const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
901
966
  agentId: route.agentId,
@@ -936,17 +1001,27 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
936
1001
  const content = extractedContent;
937
1002
  const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
938
1003
  const taskInfoConversationId = groupId || to;
939
- const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
1004
+ const agentDisplayName = getAgentDisplayName({
1005
+ subAgentOptions,
1006
+ agentId: route.agentId,
1007
+ agentsList: cfg.agents?.list,
1008
+ });
1009
+ const configuredTaskMetadata = resolveConfiguredTaskModelMetadata({
1010
+ cfg,
1011
+ agentId: route.agentId,
1012
+ });
1013
+ const sessionTaskStateScope = {
1014
+ accountId,
1015
+ conversationId: taskInfoConversationId,
1016
+ agentId: route.agentId,
1017
+ };
1018
+ const sessionTaskState = initSessionState(sessionTaskStateScope, configuredTaskMetadata);
940
1019
  const initialStatusLine =
941
1020
  renderStatusLine(
942
1021
  {
943
1022
  model: sessionTaskState.model,
944
1023
  effort: sessionTaskState.effort,
945
- agent: getAgentDisplayName({
946
- subAgentOptions,
947
- agentId: route.agentId,
948
- agentsList: cfg.agents?.list,
949
- }),
1024
+ agent: agentDisplayName,
950
1025
  },
951
1026
  dingtalkConfig,
952
1027
  ) || undefined;
@@ -966,7 +1041,6 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
966
1041
  let cardFlightKey: string | undefined;
967
1042
  if (questionContext) {
968
1043
  questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
969
- questionCardTookOver = true;
970
1044
  if (cardFlightKey) {
971
1045
  cardCreationInFlight.delete(cardFlightKey);
972
1046
  cardFlightKey = undefined;
@@ -987,23 +1061,34 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
987
1061
  log?.warn?.(
988
1062
  `[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`,
989
1063
  );
1064
+ return false;
990
1065
  }
1066
+ questionCardTookOver = true;
991
1067
  if (!currentAICard) {
992
- return;
1068
+ return true;
993
1069
  }
994
1070
  if (isCardInTerminalState(currentAICard.state)) {
995
- return;
1071
+ return true;
1072
+ }
1073
+ let recalled = false;
1074
+ try {
1075
+ recalled = await recallAICardMessage(currentAICard, log);
1076
+ } catch (err) {
1077
+ log?.warn?.(
1078
+ `[DingTalk][AskUser] Question card took over, but AI card recall errored question=${questionId} outTrackId=${outTrackId}: ${err instanceof Error ? err.message : String(err)}`,
1079
+ );
1080
+ return true;
996
1081
  }
997
- const recalled = await recallAICardMessage(currentAICard, log);
998
1082
  if (!recalled) {
999
1083
  log?.warn?.(
1000
1084
  `[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`,
1001
1085
  );
1002
- return;
1086
+ return true;
1003
1087
  }
1004
1088
  log?.info?.(
1005
1089
  `[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`,
1006
1090
  );
1091
+ return true;
1007
1092
  };
1008
1093
  }
1009
1094
 
@@ -2175,7 +2260,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2175
2260
  legacyCardStreamReasoning === undefined
2176
2261
  ? dingtalkConfig
2177
2262
  : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
2178
- const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
2263
+ const sessionTaskState = getSessionState(sessionTaskStateScope);
2179
2264
  const taskMeta = {
2180
2265
  model: sessionTaskState?.model,
2181
2266
  effort: sessionTaskState?.effort,
@@ -2183,11 +2268,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2183
2268
  typeof sessionTaskState?.taskStartTime === "number"
2184
2269
  ? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
2185
2270
  : undefined,
2186
- agent: getAgentDisplayName({
2187
- subAgentOptions,
2188
- agentId: route.agentId,
2189
- agentsList: cfg.agents?.list,
2190
- }),
2271
+ agent: agentDisplayName,
2191
2272
  };
2192
2273
 
2193
2274
  const strategy = createReplyStrategy({
@@ -2250,6 +2331,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2250
2331
  mediaUrls,
2251
2332
  audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
2252
2333
  kind: replyKind,
2334
+ isError: payload.isError === true,
2253
2335
  isReasoning: richPayload.isReasoning === true,
2254
2336
  });
2255
2337
  } catch (err: unknown) {
@@ -49,6 +49,14 @@ export function createCardReplyStrategy(
49
49
  ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
50
50
  ): ReplyStrategy {
51
51
  const { card, config, log, isStopRequested } = ctx;
52
+ const sessionTaskStateScope =
53
+ card.accountId && card.conversationId && ctx.sessionAgentId
54
+ ? {
55
+ accountId: card.accountId,
56
+ conversationId: card.contextConversationId || card.conversationId,
57
+ agentId: ctx.sessionAgentId,
58
+ }
59
+ : undefined;
52
60
 
53
61
  const buildStatusLine = (): string | undefined => {
54
62
  if (!ctx.taskMeta) {
@@ -61,8 +69,8 @@ export function createCardReplyStrategy(
61
69
  ? ctx.taskMeta.usage
62
70
  : undefined;
63
71
 
64
- const sessionTaskTimeSeconds = card.accountId && card.conversationId
65
- ? getTaskTimeSeconds(card.accountId, card.contextConversationId || card.conversationId)
72
+ const sessionTaskTimeSeconds = sessionTaskStateScope
73
+ ? getTaskTimeSeconds(sessionTaskStateScope)
66
74
  : undefined;
67
75
  const cardElapsedMs = Math.max(0, Date.now() - card.createdAt);
68
76
  const sessionElapsedMs = typeof sessionTaskTimeSeconds === "number"
@@ -439,10 +447,12 @@ export function createCardReplyStrategy(
439
447
  if (!card.accountId || !card.conversationId) {
440
448
  return;
441
449
  }
442
- updateSessionState(card.accountId, card.contextConversationId || card.conversationId, {
443
- model: selected.model,
444
- effort: selected.thinkLevel,
445
- });
450
+ if (sessionTaskStateScope) {
451
+ updateSessionState(sessionTaskStateScope, {
452
+ model: selected.model,
453
+ effort: selected.thinkLevel,
454
+ });
455
+ }
446
456
  if (ctx.taskMeta) {
447
457
  ctx.taskMeta.model = selected.model;
448
458
  ctx.taskMeta.effort = selected.thinkLevel;
@@ -470,6 +480,20 @@ export function createCardReplyStrategy(
470
480
 
471
481
  // ---- final: defer to finalize, just save text ----
472
482
  if (payload.kind === "final") {
483
+ // OpenClaw can append a non-terminal tool warning after a valid final.
484
+ // Do not let that plain-text warning replace the answer already captured by the card.
485
+ const hasPreservableReply = Boolean(
486
+ finalTextForFallback?.trim() || controller.getFinalAnswerContent().trim(),
487
+ ) || processedMediaUrls.size > 0 || pendingNonImageMedia.length > 0;
488
+ if (
489
+ payload.isError === true
490
+ && sawFinalDelivery
491
+ && payload.mediaUrls.length === 0
492
+ && hasPreservableReply
493
+ ) {
494
+ log?.debug?.("[DingTalk][Finalize] Ignoring late error final after answer delivery");
495
+ return;
496
+ }
473
497
  const isFirstFinalDelivery = !sawFinalDelivery;
474
498
  lifecycleState = "final_seen";
475
499
  await flushPendingReasoning();
@@ -30,6 +30,7 @@ export interface DeliverPayload {
30
30
  */
31
31
  audioAsVoice?: boolean;
32
32
  kind: "block" | "final" | "tool";
33
+ isError?: boolean;
33
34
  isReasoning?: boolean;
34
35
  }
35
36
 
@@ -4,36 +4,67 @@ interface SessionState {
4
4
  taskStartTime: number;
5
5
  }
6
6
 
7
+ interface SessionStateScope {
8
+ accountId: string;
9
+ conversationId: string;
10
+ agentId: string;
11
+ }
12
+
13
+ type SessionStateInitialMetadata = Partial<Pick<SessionState, "model" | "effort">>;
14
+
7
15
  const sessionStore = new Map<string, SessionState>();
8
16
 
9
- function sessionKey(accountId: string, conversationId: string): string {
10
- return `${accountId}:${conversationId}`;
17
+ function sessionKey(scope: SessionStateScope): string {
18
+ return `${scope.accountId}:${scope.agentId}:${scope.conversationId}`;
19
+ }
20
+
21
+ function normalizeMetadataValue(value: string | undefined): string | undefined {
22
+ const trimmed = typeof value === "string" ? value.trim() : "";
23
+ return trimmed || undefined;
24
+ }
25
+
26
+ function seedMissingSessionStateMetadata(
27
+ state: SessionState,
28
+ metadata?: SessionStateInitialMetadata,
29
+ ): void {
30
+ const model = normalizeMetadataValue(metadata?.model);
31
+ const effort = normalizeMetadataValue(metadata?.effort);
32
+ if (state.model === undefined && model !== undefined) {
33
+ state.model = model;
34
+ }
35
+ if (state.effort === undefined && effort !== undefined) {
36
+ state.effort = effort;
37
+ }
11
38
  }
12
39
 
13
- export function initSessionState(accountId: string, conversationId: string): SessionState {
14
- const key = sessionKey(accountId, conversationId);
40
+ export function initSessionState(
41
+ scope: SessionStateScope,
42
+ metadata?: SessionStateInitialMetadata,
43
+ ): SessionState {
44
+ const key = sessionKey(scope);
15
45
  const existing = sessionStore.get(key);
16
46
  if (existing) {
17
47
  existing.taskStartTime = Date.now();
48
+ seedMissingSessionStateMetadata(existing, metadata);
18
49
  return existing;
19
50
  }
20
51
  const state: SessionState = {
21
52
  taskStartTime: Date.now(),
22
53
  };
54
+ seedMissingSessionStateMetadata(state, metadata);
23
55
  sessionStore.set(key, state);
24
56
  return state;
25
57
  }
26
58
 
27
- export function getSessionState(accountId: string, conversationId: string): SessionState | undefined {
28
- return sessionStore.get(sessionKey(accountId, conversationId));
59
+ export function getSessionState(scope: SessionStateScope): SessionState | undefined {
60
+ return sessionStore.get(sessionKey(scope));
29
61
  }
30
62
 
31
63
  export function updateSessionState(
32
- accountId: string,
33
- conversationId: string,
64
+ scope: SessionStateScope,
34
65
  patch: Partial<Pick<SessionState, "model" | "effort">>,
35
66
  ): void {
36
- const state = sessionStore.get(sessionKey(accountId, conversationId));
67
+ const state = sessionStore.get(sessionKey(scope));
37
68
  if (!state) {
38
69
  return;
39
70
  }
@@ -45,16 +76,16 @@ export function updateSessionState(
45
76
  }
46
77
  }
47
78
 
48
- export function getTaskTimeSeconds(accountId: string, conversationId: string): number | undefined {
49
- const state = sessionStore.get(sessionKey(accountId, conversationId));
79
+ export function getTaskTimeSeconds(scope: SessionStateScope): number | undefined {
80
+ const state = sessionStore.get(sessionKey(scope));
50
81
  if (!state) {
51
82
  return undefined;
52
83
  }
53
84
  return Math.round((Date.now() - state.taskStartTime) / 1000);
54
85
  }
55
86
 
56
- export function clearSessionState(accountId: string, conversationId: string): void {
57
- sessionStore.delete(sessionKey(accountId, conversationId));
87
+ export function clearSessionState(scope: SessionStateScope): void {
88
+ sessionStore.delete(sessionKey(scope));
58
89
  }
59
90
 
60
91
  export function clearAllSessionStatesForTest(): void {
@@ -20,9 +20,11 @@ import type {
20
20
  HandleDingTalkMessageParams,
21
21
  Logger,
22
22
  MessageContent,
23
+ ResolvedDingTalkRoute,
23
24
  } from "../types";
24
25
  import { getErrorMessage } from "../utils";
25
26
  import { resolveAtAgents } from "./agent-name-matcher";
27
+ import type { ResolvedDingTalkSessionPeer } from "../session-routing";
26
28
 
27
29
  export class HostRoutingHelperUnavailableError extends Error {
28
30
  constructor(
@@ -84,6 +86,11 @@ export type MessageTarget =
84
86
  }
85
87
  | { kind: "subagent-command"; agent: AgentNameMatch; commandText: string };
86
88
 
89
+ export interface ResolvedSubAgentTarget {
90
+ agent: AgentNameMatch;
91
+ route: ResolvedDingTalkRoute;
92
+ }
93
+
87
94
  /**
88
95
  * Resolve how an inbound message should be routed.
89
96
  *
@@ -198,6 +205,8 @@ export async function dispatchSubAgents(params: {
198
205
  dingtalkConfig: DingTalkConfig;
199
206
  sessionWebhook: string;
200
207
  extractedContent: MessageContent;
208
+ sessionPeer: ResolvedDingTalkSessionPeer;
209
+ onRoutesResolved?: (targets: readonly ResolvedSubAgentTarget[]) => void;
201
210
  handleMessage: (params: HandleDingTalkMessageParams) => Promise<void>;
202
211
  downloadMedia: (
203
212
  config: DingTalkConfig,
@@ -215,11 +224,64 @@ export async function dispatchSubAgents(params: {
215
224
  dingtalkConfig,
216
225
  sessionWebhook,
217
226
  extractedContent,
227
+ sessionPeer,
228
+ onRoutesResolved,
218
229
  handleMessage,
219
230
  downloadMedia: download,
220
231
  log,
221
232
  } = params;
222
233
 
234
+ let helperMissingWarningSent = false;
235
+ const sendHelperMissingWarning = async (): Promise<void> => {
236
+ if (helperMissingWarningSent) {
237
+ return;
238
+ }
239
+ helperMissingWarningSent = true;
240
+ try {
241
+ const isGroup = data.conversationType !== "1";
242
+ const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
243
+ await sendBySession(
244
+ dingtalkConfig,
245
+ sessionWebhook,
246
+ "⚠️ 当前宿主版本不支持 DingTalk 子助手路由所需的 session helper,请升级 OpenClaw 后重试。",
247
+ sendOptions,
248
+ );
249
+ } catch (notifyError: unknown) {
250
+ log?.debug?.(
251
+ `[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`,
252
+ );
253
+ }
254
+ };
255
+
256
+ let resolvedTargets: ResolvedSubAgentTarget[];
257
+ try {
258
+ const rt = getDingTalkRuntime();
259
+ resolvedTargets = matchedAgents.map((agent) => ({
260
+ agent,
261
+ route: {
262
+ agentId: agent.agentId,
263
+ sessionKey: buildAgentSessionKey({
264
+ rt,
265
+ cfg,
266
+ accountId,
267
+ agentId: agent.agentId,
268
+ peerKind: sessionPeer.kind,
269
+ peerId: sessionPeer.peerId,
270
+ }),
271
+ mainSessionKey: "",
272
+ },
273
+ }));
274
+ } catch (error) {
275
+ const message = getErrorMessage(error);
276
+ log?.error?.(`[DingTalk] Failed to resolve sub-agent routes: ${message}`);
277
+ if (error instanceof HostRoutingHelperUnavailableError) {
278
+ await sendHelperMissingWarning();
279
+ return;
280
+ }
281
+ throw error;
282
+ }
283
+ onRoutesResolved?.(resolvedTargets);
284
+
223
285
  // Pre-download media once to avoid duplication across sub-agents
224
286
  let preDownloadedMedia:
225
287
  | {
@@ -255,9 +317,8 @@ export async function dispatchSubAgents(params: {
255
317
  };
256
318
  }
257
319
  }
258
- let helperMissingWarningSent = false;
259
-
260
- for (const agentMatch of matchedAgents) {
320
+ for (const target of resolvedTargets) {
321
+ const agentMatch = target.agent;
261
322
  try {
262
323
  await handleMessage({
263
324
  cfg,
@@ -266,6 +327,7 @@ export async function dispatchSubAgents(params: {
266
327
  sessionWebhook,
267
328
  log,
268
329
  dingtalkConfig,
330
+ routeOverride: target.route,
269
331
  subAgentOptions: {
270
332
  agentId: agentMatch.agentId,
271
333
  responsePrefix: commandText
@@ -279,22 +341,8 @@ export async function dispatchSubAgents(params: {
279
341
  } catch (error) {
280
342
  const message = getErrorMessage(error);
281
343
  log?.error?.(`[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${message}`);
282
- if (error instanceof HostRoutingHelperUnavailableError && !helperMissingWarningSent) {
283
- helperMissingWarningSent = true;
284
- try {
285
- const isGroup = data.conversationType !== "1";
286
- const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
287
- await sendBySession(
288
- dingtalkConfig,
289
- sessionWebhook,
290
- "⚠️ 当前宿主版本不支持 DingTalk 子助手路由所需的 session helper,请升级 OpenClaw 后重试。",
291
- sendOptions,
292
- );
293
- } catch (notifyError: unknown) {
294
- log?.debug?.(
295
- `[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`,
296
- );
297
- }
344
+ if (error instanceof HostRoutingHelperUnavailableError) {
345
+ await sendHelperMissingWarning();
298
346
  }
299
347
  }
300
348
  }
package/src/types.ts CHANGED
@@ -462,6 +462,16 @@ export interface SubAgentOptions {
462
462
  commandText?: string;
463
463
  }
464
464
 
465
+ /**
466
+ * A trusted route resolved by the plugin before recursive or synthetic inbound handling.
467
+ * It is runtime-only and must never be populated from DingTalk callback payload fields.
468
+ */
469
+ export interface ResolvedDingTalkRoute {
470
+ agentId: string;
471
+ sessionKey: string;
472
+ mainSessionKey: string;
473
+ }
474
+
465
475
  /**
466
476
  * Message handler parameters
467
477
  */
@@ -472,6 +482,10 @@ export interface HandleDingTalkMessageParams {
472
482
  sessionWebhook: string;
473
483
  log?: Logger;
474
484
  dingtalkConfig: DingTalkConfig;
485
+ /** Distinguishes real Stream messages from Ask User callback reinjection. */
486
+ inboundOrigin?: "stream" | "ask-user";
487
+ /** Reuses an already-resolved trusted route for recursive or synthetic handling. */
488
+ routeOverride?: ResolvedDingTalkRoute;
475
489
  /**
476
490
  * When set, routes message to the specified sub-agent instead of main agent.
477
491
  * This enables reuse of the main message handling logic for sub-agents.