@soimy/dingtalk 3.6.4 → 3.6.6

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 (36) hide show
  1. package/README.md +14 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +7207 -6169
  5. package/dist/index.js.map +4 -4
  6. package/dist/src/card/ask-user-question-context.d.ts +17 -0
  7. package/dist/src/card/ask-user-question-context.d.ts.map +1 -0
  8. package/dist/src/card/ask-user-question.d.ts +241 -0
  9. package/dist/src/card/ask-user-question.d.ts.map +1 -0
  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 +5 -0
  13. package/dist/src/card/card-template.d.ts.map +1 -1
  14. package/dist/src/card/task-model-metadata.d.ts +11 -0
  15. package/dist/src/card/task-model-metadata.d.ts.map +1 -0
  16. package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
  17. package/dist/src/inbound-handler.d.ts.map +1 -1
  18. package/dist/src/reply-strategy-card.d.ts.map +1 -1
  19. package/dist/src/reply-strategy-types.d.ts +1 -0
  20. package/dist/src/reply-strategy-types.d.ts.map +1 -1
  21. package/dist/src/session-state.d.ts +11 -5
  22. package/dist/src/session-state.d.ts.map +1 -1
  23. package/docs/assets/dingtalk-ask-user-card-template.json +6 -0
  24. package/index.ts +18 -1
  25. package/openclaw.plugin.json +3 -0
  26. package/package.json +2 -1
  27. package/src/card/ask-user-question-context.ts +31 -0
  28. package/src/card/ask-user-question.ts +1068 -0
  29. package/src/card/card-action-handler.ts +14 -0
  30. package/src/card/card-template.ts +10 -0
  31. package/src/card/task-model-metadata.ts +51 -0
  32. package/src/gateway/channel-gateway.ts +5 -5
  33. package/src/inbound-handler.ts +124 -21
  34. package/src/reply-strategy-card.ts +30 -6
  35. package/src/reply-strategy-types.ts +1 -0
  36. package/src/session-state.ts +44 -13
@@ -1,6 +1,7 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
2
  import type { CardCallbackAnalysis } from "../card-callback-service";
3
3
  import type { DingTalkConfig, Logger } from "../types";
4
+ import { handleDingTalkAskUserCardCallback } from "./ask-user-question";
4
5
  import { resolveCardRun } from "./card-run-registry";
5
6
  import { stopCardRun } from "./card-stop-handler";
6
7
 
@@ -9,12 +10,25 @@ export interface CardActionResult {
9
10
  }
10
11
 
11
12
  export async function handleCardAction(params: {
13
+ payload: unknown;
12
14
  analysis: CardCallbackAnalysis;
13
15
  cfg: OpenClawConfig;
14
16
  accountId: string;
15
17
  config: DingTalkConfig;
16
18
  log?: Logger;
17
19
  }): Promise<CardActionResult> {
20
+ const askUserResult = await handleDingTalkAskUserCardCallback({
21
+ payload: params.payload,
22
+ cfg: params.cfg,
23
+ accountId: params.accountId,
24
+ config: params.config,
25
+ clickerUserId: params.analysis.userId,
26
+ log: params.log,
27
+ });
28
+ if (askUserResult.handled) {
29
+ return askUserResult;
30
+ }
31
+
18
32
  if (params.analysis.actionId !== "btn_stop") {
19
33
  return { handled: false };
20
34
  }
@@ -8,6 +8,8 @@ export const BUILTIN_DINGTALK_CARD_TEMPLATE_ID =
8
8
  export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
9
9
  export const BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY = "blockList";
10
10
  export const BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY = "copy_content";
11
+ export const BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID =
12
+ "c2a6355b-9724-4f7e-9653-d33fcb3311bb.schema";
11
13
 
12
14
  export interface DingTalkCardTemplateContract {
13
15
  templateId: string;
@@ -20,6 +22,10 @@ export interface DingTalkCardTemplateContract {
20
22
  copyContentKey: string;
21
23
  }
22
24
 
25
+ export interface DingTalkAskUserCardTemplateContract {
26
+ templateId: string;
27
+ }
28
+
23
29
  /** Frozen singleton — no allocation on every call. */
24
30
  export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Object.freeze({
25
31
  templateId: BUILTIN_DINGTALK_CARD_TEMPLATE_ID,
@@ -29,3 +35,7 @@ export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Ob
29
35
  copyContentKey: BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY,
30
36
  });
31
37
 
38
+ export const DINGTALK_ASK_USER_CARD_TEMPLATE: Readonly<DingTalkAskUserCardTemplateContract> =
39
+ Object.freeze({
40
+ templateId: BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID,
41
+ });
@@ -0,0 +1,51 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+
3
+ export interface InitialTaskModelMetadata {
4
+ model?: string;
5
+ effort?: string;
6
+ }
7
+
8
+ type AgentModelConfigLike = string | { primary?: unknown } | undefined;
9
+
10
+ function readPrimaryModelRef(value: AgentModelConfigLike): string | undefined {
11
+ if (typeof value === "string") {
12
+ return value.trim() || undefined;
13
+ }
14
+ if (value && typeof value === "object" && typeof value.primary === "string") {
15
+ return value.primary.trim() || undefined;
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ export function normalizeModelDisplayName(modelRef: string | undefined): string | undefined {
21
+ const trimmed = typeof modelRef === "string" ? modelRef.trim() : "";
22
+ if (!trimmed) {
23
+ return undefined;
24
+ }
25
+ const parts = trimmed.split("/").map((part) => part.trim()).filter(Boolean);
26
+ return parts.at(-1) || undefined;
27
+ }
28
+
29
+ export function resolveConfiguredTaskModelMetadata(params: {
30
+ cfg: OpenClawConfig;
31
+ agentId?: string | null;
32
+ }): InitialTaskModelMetadata {
33
+ const agents = params.cfg.agents;
34
+ const agentId = String(params.agentId || "").trim();
35
+ const agent = Array.isArray(agents?.list)
36
+ ? agents.list.find((entry) => String(entry.id || "").trim() === agentId)
37
+ : undefined;
38
+
39
+ const modelRef = readPrimaryModelRef(agent?.model) ?? readPrimaryModelRef(agents?.defaults?.model);
40
+ const effort =
41
+ typeof agent?.thinkingDefault === "string" && agent.thinkingDefault.trim()
42
+ ? agent.thinkingDefault.trim()
43
+ : typeof agents?.defaults?.thinkingDefault === "string" && agents.defaults.thinkingDefault.trim()
44
+ ? agents.defaults.thinkingDefault.trim()
45
+ : undefined;
46
+
47
+ return {
48
+ model: normalizeModelDisplayName(modelRef),
49
+ effort,
50
+ };
51
+ }
@@ -1,10 +1,7 @@
1
1
  import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
2
2
  import { analyzeCardCallback } from "../card-callback-service";
3
+ import { finalizeActiveCardsForAccount, recoverPendingCardsForAccount } from "../card-service";
3
4
  import { handleCardAction } from "../card/card-action-handler";
4
- import {
5
- finalizeActiveCardsForAccount,
6
- recoverPendingCardsForAccount,
7
- } from "../card-service";
8
5
  import { resolveRobotCode, resolveRuntimeConfig } from "../config";
9
6
  import { ConnectionManager } from "../connection-manager";
10
7
  import { isMessageProcessed, markMessageProcessed } from "../dedup";
@@ -381,6 +378,7 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
381
378
  }
382
379
  }
383
380
  const actionResult = await handleCardAction({
381
+ payload,
384
382
  analysis,
385
383
  cfg,
386
384
  accountId: account.accountId,
@@ -500,7 +498,9 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
500
498
  lastStartAt: getCurrentTimestamp(),
501
499
  lastError: null,
502
500
  });
503
- pluginLog?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
501
+ pluginLog?.info?.(
502
+ `[${account.accountId}] DingTalk Stream client connected successfully`,
503
+ );
504
504
  await nativeStopPromise;
505
505
  }
506
506
  } catch (err: any) {
@@ -7,9 +7,20 @@ import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
7
7
  import { attachNativeAckReaction } from "./ack-reaction-service";
8
8
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
9
9
  import { getAccessToken } from "./auth";
10
- import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
10
+ import {
11
+ createAICard,
12
+ commitAICardBlocks,
13
+ isCardInTerminalState,
14
+ recallAICardMessage,
15
+ } from "./card-service";
16
+ import {
17
+ getDingTalkQuestionContext,
18
+ withDingTalkQuestionContext,
19
+ } from "./card/ask-user-question-context";
11
20
  import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
12
21
  import { renderStatusLine } from "./card/statusline-renderer";
22
+ import { resolveConfiguredTaskModelMetadata } from "./card/task-model-metadata";
23
+ import { dispatchDingTalkCardStopCommand } from "./command/card-stop-command";
13
24
  import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
14
25
  import {
15
26
  resolveAckReactionSetting,
@@ -418,6 +429,7 @@ function buildGroupTurnContextPrompt(params: {
418
429
 
419
430
  type ReplyStreamPayload = {
420
431
  text?: string;
432
+ isError?: boolean;
421
433
  isReasoning?: boolean;
422
434
  mediaUrl?: string;
423
435
  mediaUrls?: string[];
@@ -548,6 +560,23 @@ export async function downloadMedia(
548
560
  }
549
561
 
550
562
  export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
563
+ // Keep context creation inside this public inbound entry. Ask-user synthetic
564
+ // reinjections call handleDingTalkMessage directly, so moving this wrapper to
565
+ // gateway callbacks would lose per-message isolation for reinjected answers.
566
+ return withDingTalkQuestionContext(
567
+ {
568
+ cfg: params.cfg,
569
+ accountId: params.accountId,
570
+ data: params.data,
571
+ sessionWebhook: params.sessionWebhook,
572
+ log: params.log,
573
+ dingtalkConfig: params.dingtalkConfig,
574
+ },
575
+ () => handleDingTalkMessageInner(params),
576
+ );
577
+ }
578
+
579
+ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams): Promise<void> {
551
580
  const {
552
581
  cfg,
553
582
  accountId,
@@ -809,6 +838,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
809
838
  peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
810
839
  });
811
840
  }
841
+ const questionContext = getDingTalkQuestionContext();
842
+ if (questionContext) {
843
+ questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
844
+ }
812
845
 
813
846
  // @Sub-Agent routing: dispatch @mention-targeted messages to their agent(s).
814
847
  // Both content (`@agent <message>`) and commands (`@agent /new`) re-enter
@@ -905,17 +938,27 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
905
938
  const content = extractedContent;
906
939
  const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
907
940
  const taskInfoConversationId = groupId || to;
908
- const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
941
+ const agentDisplayName = getAgentDisplayName({
942
+ subAgentOptions,
943
+ agentId: route.agentId,
944
+ agentsList: cfg.agents?.list,
945
+ });
946
+ const configuredTaskMetadata = resolveConfiguredTaskModelMetadata({
947
+ cfg,
948
+ agentId: route.agentId,
949
+ });
950
+ const sessionTaskStateScope = {
951
+ accountId,
952
+ conversationId: taskInfoConversationId,
953
+ agentId: route.agentId,
954
+ };
955
+ const sessionTaskState = initSessionState(sessionTaskStateScope, configuredTaskMetadata);
909
956
  const initialStatusLine =
910
957
  renderStatusLine(
911
958
  {
912
959
  model: sessionTaskState.model,
913
960
  effort: sessionTaskState.effort,
914
- agent: getAgentDisplayName({
915
- subAgentOptions,
916
- agentId: route.agentId,
917
- agentsList: cfg.agents?.list,
918
- }),
961
+ agent: agentDisplayName,
919
962
  },
920
963
  dingtalkConfig,
921
964
  ) || undefined;
@@ -930,8 +973,52 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
930
973
  // sending a separate plain-text message.
931
974
  let useCardMode = dingtalkConfig.messageType === "card";
932
975
  let currentAICard: import("./types").AICardInstance | undefined;
976
+ let questionCardTookOver = false;
933
977
 
934
978
  let cardFlightKey: string | undefined;
979
+ if (questionContext) {
980
+ questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
981
+ questionCardTookOver = true;
982
+ if (cardFlightKey) {
983
+ cardCreationInFlight.delete(cardFlightKey);
984
+ cardFlightKey = undefined;
985
+ }
986
+ try {
987
+ await dispatchDingTalkCardStopCommand({
988
+ cfg,
989
+ accountId,
990
+ agentId: route.agentId,
991
+ targetSessionKey: route.sessionKey,
992
+ clickerUserId: senderId || "unknown",
993
+ log,
994
+ });
995
+ log?.info?.(
996
+ `[DingTalk][AskUser] Dispatched targeted stop after question card sent question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}`,
997
+ );
998
+ } catch (err) {
999
+ log?.warn?.(
1000
+ `[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`,
1001
+ );
1002
+ }
1003
+ if (!currentAICard) {
1004
+ return;
1005
+ }
1006
+ if (isCardInTerminalState(currentAICard.state)) {
1007
+ return;
1008
+ }
1009
+ const recalled = await recallAICardMessage(currentAICard, log);
1010
+ if (!recalled) {
1011
+ log?.warn?.(
1012
+ `[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`,
1013
+ );
1014
+ return;
1015
+ }
1016
+ log?.info?.(
1017
+ `[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`,
1018
+ );
1019
+ };
1020
+ }
1021
+
935
1022
  if (useCardMode && !isBtwBypass) {
936
1023
  const key = `${accountId}:${to}`;
937
1024
  if (cardCreationInFlight.has(key)) {
@@ -2100,7 +2187,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
2100
2187
  legacyCardStreamReasoning === undefined
2101
2188
  ? dingtalkConfig
2102
2189
  : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
2103
- const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
2190
+ const sessionTaskState = getSessionState(sessionTaskStateScope);
2104
2191
  const taskMeta = {
2105
2192
  model: sessionTaskState?.model,
2106
2193
  effort: sessionTaskState?.effort,
@@ -2108,11 +2195,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
2108
2195
  typeof sessionTaskState?.taskStartTime === "number"
2109
2196
  ? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
2110
2197
  : undefined,
2111
- agent: getAgentDisplayName({
2112
- subAgentOptions,
2113
- agentId: route.agentId,
2114
- agentsList: cfg.agents?.list,
2115
- }),
2198
+ agent: agentDisplayName,
2116
2199
  };
2117
2200
 
2118
2201
  const strategy = createReplyStrategy({
@@ -2163,11 +2246,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
2163
2246
  const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
2164
2247
  const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
2165
2248
  const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
2249
+ const replyKind = (info?.kind as DeliverPayload["kind"]) || "block";
2250
+ if (questionCardTookOver) {
2251
+ log?.info?.(
2252
+ `[DingTalk][AskUser] Suppressed ${replyKind} reply after question card took over`,
2253
+ );
2254
+ return;
2255
+ }
2166
2256
  await strategy.deliver({
2167
2257
  text: inlineReplyPayload.text,
2168
2258
  mediaUrls,
2169
2259
  audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
2170
- kind: (info?.kind as DeliverPayload["kind"]) || "block",
2260
+ kind: replyKind,
2261
+ isError: payload.isError === true,
2171
2262
  isReasoning: richPayload.isReasoning === true,
2172
2263
  });
2173
2264
  } catch (err: unknown) {
@@ -2212,13 +2303,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
2212
2303
  typeof bufferedFinalPayload.text === "string" &&
2213
2304
  bufferedFinalPayload.text.trim().length > 0;
2214
2305
  if (hasBufferedText || mediaUrls.length > 0) {
2215
- await strategy.deliver({
2216
- text: inlineReplyPayload.text,
2217
- mediaUrls,
2218
- audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
2219
- kind: "final",
2220
- isReasoning: bufferedFinalPayload.isReasoning === true,
2221
- });
2306
+ if (questionCardTookOver) {
2307
+ log?.info?.(
2308
+ "[DingTalk][AskUser] Suppressed buffered final after question card took over",
2309
+ );
2310
+ } else {
2311
+ await strategy.deliver({
2312
+ text: inlineReplyPayload.text,
2313
+ mediaUrls,
2314
+ audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
2315
+ kind: "final",
2316
+ isReasoning: bufferedFinalPayload.isReasoning === true,
2317
+ });
2318
+ }
2222
2319
  }
2223
2320
  }
2224
2321
  } catch (dispatchErr: unknown) {
@@ -2228,6 +2325,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
2228
2325
  throw dispatchErr;
2229
2326
  }
2230
2327
 
2328
+ if (questionCardTookOver) {
2329
+ log?.info?.(
2330
+ "[DingTalk][AskUser] Skipping normal AI card finalize after question card took over",
2331
+ );
2332
+ return;
2333
+ }
2231
2334
  await strategy.finalize();
2232
2335
  } finally {
2233
2336
  // Only remove the registry entry if no stop was requested. When a stop is
@@ -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 {