@soimy/dingtalk 3.5.1 → 3.5.3

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 +13 -24
  2. package/openclaw.plugin.json +695 -0
  3. package/package.json +12 -7
  4. package/src/ack-reaction-service.ts +1 -1
  5. package/src/auth.ts +1 -1
  6. package/src/card/card-action-handler.ts +1 -1
  7. package/src/card/card-stop-handler.ts +1 -1
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/reasoning-answer-split.ts +162 -0
  10. package/src/card/reasoning-block-assembler.ts +157 -0
  11. package/src/card-callback-service.ts +1 -1
  12. package/src/card-draft-controller.ts +117 -6
  13. package/src/card-service.ts +112 -1
  14. package/src/channel.ts +131 -96
  15. package/src/command/card-stop-command.ts +4 -22
  16. package/src/command/inbound-command-dispatch-service.ts +464 -0
  17. package/src/config-schema.ts +62 -38
  18. package/src/config.ts +25 -3
  19. package/src/docs-service.ts +5 -5
  20. package/src/http-client.ts +20 -0
  21. package/src/inbound-handler.ts +475 -501
  22. package/src/logger-context.ts +16 -2
  23. package/src/media-utils.ts +166 -10
  24. package/src/message-utils.ts +33 -5
  25. package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
  26. package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +14 -9
  27. package/src/onboarding.ts +29 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +294 -28
  30. package/src/reply-strategy-markdown.ts +124 -19
  31. package/src/reply-strategy.ts +22 -2
  32. package/src/send-service.ts +178 -7
  33. package/src/targeting/agent-routing.ts +55 -32
  34. package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
  35. package/src/types.ts +60 -4
  36. package/src/utils.ts +190 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -46,7 +46,7 @@
46
46
  "type-check": "tsc -p tsconfig.json"
47
47
  },
48
48
  "dependencies": {
49
- "axios": "^1.6.0",
49
+ "axios": "1.13.6",
50
50
  "dingtalk-stream": "^2.1.4",
51
51
  "form-data": "^4.0.0",
52
52
  "mammoth": "^1.12.0",
@@ -58,20 +58,25 @@
58
58
  "@vitest/coverage-v8": "^3.2.4",
59
59
  "oxfmt": "0.34.0",
60
60
  "oxlint": "^1.49.0",
61
- "oxlint-tsgolint": "^0.15.0",
61
+ "oxlint-tsgolint": "^0.18.0",
62
62
  "typescript": "^5.3.0",
63
63
  "vitepress": "1.6.4",
64
64
  "vitest": "^3.2.4"
65
65
  },
66
66
  "peerDependencies": {
67
- "openclaw": ">=2026.3.24"
67
+ "openclaw": ">=2026.3.28"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "openclaw": {
71
+ "optional": true
72
+ }
68
73
  },
69
74
  "openclaw": {
70
75
  "compat": {
71
- "pluginApi": ">=2026.3.24"
76
+ "pluginApi": ">=2026.3.28"
72
77
  },
73
78
  "build": {
74
- "openclawVersion": "2026.3.24"
79
+ "openclawVersion": "2026.3.28"
75
80
  },
76
81
  "extensions": [
77
82
  "./index.ts"
@@ -94,7 +99,7 @@
94
99
  ]
95
100
  },
96
101
  "install": {
97
- "minHostVersion": ">=2026.3.24",
102
+ "minHostVersion": ">=2026.3.28",
98
103
  "npmSpec": "@soimy/dingtalk",
99
104
  "localPath": ".",
100
105
  "defaultChoice": "npm"
@@ -1,4 +1,4 @@
1
- import axios from "axios";
1
+ import axios from "./http-client";
2
2
  import { getAccessToken } from "./auth";
3
3
  import type { DingTalkConfig } from "./types";
4
4
  import { formatDingTalkErrorPayloadLog, getErrorMessage, getProxyBypassOption } from "./utils";
package/src/auth.ts CHANGED
@@ -1,4 +1,4 @@
1
- import axios from "axios";
1
+ import axios from "./http-client";
2
2
  import type { DingTalkConfig, Logger, TokenInfo } from "./types";
3
3
  import { retryWithBackoff } from "./utils";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
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
4
  import { resolveCardRun } from "./card-run-registry";
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
2
  import { getAccessToken } from "../auth";
3
3
  import { finishStoppedAICard, hideCardStopButton } from "../card-service";
4
4
  import { dispatchDingTalkCardStopCommand } from "../command/card-stop-command";
@@ -0,0 +1,30 @@
1
+ import type { DingTalkConfig } from "../types";
2
+
3
+ export type CardStreamingMode = "off" | "answer" | "all";
4
+
5
+ // Process-lifetime one-shot warnings are intentional here: a given config key
6
+ // should only emit the deprecation notice once per runtime.
7
+ const warnedLegacyConfigs = new Set<string>();
8
+
9
+ export function resolveCardStreamingMode(
10
+ config: Pick<DingTalkConfig, "cardStreamingMode" | "cardRealTimeStream">,
11
+ ): {
12
+ mode: CardStreamingMode;
13
+ usedDeprecatedCardRealTimeStream: boolean;
14
+ } {
15
+ if (config.cardStreamingMode) {
16
+ return { mode: config.cardStreamingMode, usedDeprecatedCardRealTimeStream: false };
17
+ }
18
+ if (config.cardRealTimeStream === true) {
19
+ return { mode: "all", usedDeprecatedCardRealTimeStream: true };
20
+ }
21
+ return { mode: "off", usedDeprecatedCardRealTimeStream: false };
22
+ }
23
+
24
+ export function shouldWarnDeprecatedCardRealTimeStreamOnce(configKey: string): boolean {
25
+ if (warnedLegacyConfigs.has(configKey)) {
26
+ return false;
27
+ }
28
+ warnedLegacyConfigs.add(configKey);
29
+ return true;
30
+ }
@@ -0,0 +1,162 @@
1
+ export interface CardReasoningAnswerSplit {
2
+ reasoningText?: string;
3
+ answerText?: string;
4
+ }
5
+
6
+ const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi;
7
+
8
+ function isWrappedReasoningLine(line: string): boolean {
9
+ const trimmed = line.trim();
10
+ return trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 3;
11
+ }
12
+
13
+ function cleanReasoningLine(line: string): string {
14
+ const trimmed = line.trim();
15
+ if (!trimmed) {
16
+ return "";
17
+ }
18
+ return trimmed.replace(/^_/, "").replace(/_$/, "").trim();
19
+ }
20
+
21
+ function joinAnswerParts(parts: string[]): string | undefined {
22
+ const joined = parts.map((part) => part.trim()).filter(Boolean).join("\n\n").trim();
23
+ return joined || undefined;
24
+ }
25
+
26
+ function hasStructuredMarkdown(text: string): boolean {
27
+ const trimmed = text.trim();
28
+ if (!trimmed) {
29
+ return false;
30
+ }
31
+ return (
32
+ trimmed.includes("**")
33
+ || trimmed.includes("```")
34
+ || /(^|\n)\s*(?:[-*+]\s|#{1,6}\s|>\s|\d+\.\s)/.test(trimmed)
35
+ );
36
+ }
37
+
38
+ function splitTopLevelReasoningPrefix(text: string): CardReasoningAnswerSplit | null {
39
+ const markerIndex = text.indexOf("Reasoning:");
40
+ if (markerIndex < 0) {
41
+ return null;
42
+ }
43
+
44
+ const before = text.slice(0, markerIndex).trim();
45
+ if (before && hasStructuredMarkdown(before)) {
46
+ return {
47
+ answerText: text,
48
+ };
49
+ }
50
+
51
+ const trailing = text.slice(markerIndex + "Reasoning:".length);
52
+ const lines = trailing.split("\n");
53
+ const reasoningLines: string[] = [];
54
+ let started = false;
55
+ let remainderIndex = -1;
56
+
57
+ for (let index = 0; index < lines.length; index += 1) {
58
+ const line = lines[index];
59
+ const trimmed = line.trim();
60
+
61
+ if (!started) {
62
+ if (!trimmed) {
63
+ continue;
64
+ }
65
+ if (!isWrappedReasoningLine(trimmed)) {
66
+ return {
67
+ answerText: text,
68
+ };
69
+ }
70
+ started = true;
71
+ reasoningLines.push(cleanReasoningLine(trimmed));
72
+ continue;
73
+ }
74
+
75
+ if (!trimmed) {
76
+ continue;
77
+ }
78
+
79
+ if (isWrappedReasoningLine(trimmed)) {
80
+ reasoningLines.push(cleanReasoningLine(trimmed));
81
+ continue;
82
+ }
83
+
84
+ remainderIndex = index;
85
+ break;
86
+ }
87
+
88
+ if (reasoningLines.length === 0) {
89
+ return {
90
+ answerText: text,
91
+ };
92
+ }
93
+
94
+ const after = remainderIndex >= 0 ? lines.slice(remainderIndex).join("\n").trim() : "";
95
+ return {
96
+ reasoningText: reasoningLines.join("\n").trim() || undefined,
97
+ answerText: joinAnswerParts([before, after]),
98
+ };
99
+ }
100
+
101
+ function splitTopLevelThinkingTags(text: string): CardReasoningAnswerSplit | null {
102
+ if (!THINKING_TAG_RE.test(text)) {
103
+ return null;
104
+ }
105
+ THINKING_TAG_RE.lastIndex = 0;
106
+
107
+ let reasoning = "";
108
+ let answer = "";
109
+ let lastIndex = 0;
110
+ let inThinking = false;
111
+
112
+ for (const match of text.matchAll(THINKING_TAG_RE)) {
113
+ const matchIndex = match.index ?? 0;
114
+ const segment = text.slice(lastIndex, matchIndex);
115
+ if (inThinking) {
116
+ reasoning += segment;
117
+ } else {
118
+ answer += segment;
119
+ }
120
+ inThinking = match[1] !== "/";
121
+ lastIndex = matchIndex + match[0].length;
122
+ }
123
+
124
+ const tail = text.slice(lastIndex);
125
+ if (inThinking) {
126
+ reasoning += tail;
127
+ } else {
128
+ answer += tail;
129
+ }
130
+
131
+ const cleanedReasoning = reasoning.trim();
132
+ if (!cleanedReasoning) {
133
+ return {
134
+ answerText: text,
135
+ };
136
+ }
137
+
138
+ return {
139
+ reasoningText: cleanedReasoning,
140
+ answerText: answer.trim() || undefined,
141
+ };
142
+ }
143
+
144
+ export function splitCardReasoningAnswerText(text?: string): CardReasoningAnswerSplit {
145
+ if (typeof text !== "string") {
146
+ return {};
147
+ }
148
+
149
+ const prefixed = splitTopLevelReasoningPrefix(text);
150
+ if (prefixed) {
151
+ return prefixed;
152
+ }
153
+
154
+ const tagged = splitTopLevelThinkingTags(text);
155
+ if (tagged) {
156
+ return tagged;
157
+ }
158
+
159
+ return {
160
+ answerText: text,
161
+ };
162
+ }
@@ -0,0 +1,157 @@
1
+ export interface ReasoningBlockAssembler {
2
+ ingestSnapshot: (text: string | undefined) => string[];
3
+ flushPendingAtBoundary: () => string[];
4
+ reset: () => void;
5
+ }
6
+
7
+ function stripReasoningPrefix(text: string): string {
8
+ const trimmed = text.trim();
9
+ if (trimmed.startsWith("Reasoning:")) {
10
+ return trimmed.slice("Reasoning:".length).trimStart();
11
+ }
12
+ return trimmed;
13
+ }
14
+
15
+ function cleanReasoningLine(line: string): string {
16
+ return line.trim().replace(/^_/, "").replace(/_$/, "").trim();
17
+ }
18
+
19
+ function isClosedReasoningLine(line: string): boolean {
20
+ const trimmed = line.trim();
21
+ return trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 2;
22
+ }
23
+
24
+ function startsReasonBlock(line: string): boolean {
25
+ return cleanReasoningLine(line).startsWith("Reason:");
26
+ }
27
+
28
+ function blocksStartWithPrefix(blocks: string[], prefix: string[]): boolean {
29
+ if (prefix.length > blocks.length) {
30
+ return false;
31
+ }
32
+ return prefix.every((entry, index) => blocks[index] === entry);
33
+ }
34
+
35
+ function parseReasoningSnapshot(text: string | undefined): {
36
+ completeBlocks: string[];
37
+ pendingBlock: string;
38
+ } {
39
+ const normalized = typeof text === "string" ? stripReasoningPrefix(text) : "";
40
+ if (!normalized.trim()) {
41
+ return {
42
+ completeBlocks: [],
43
+ pendingBlock: "",
44
+ };
45
+ }
46
+
47
+ const lines = normalized
48
+ .split("\n")
49
+ .map((line) => line.trim())
50
+ .filter((line) => line.length > 0);
51
+
52
+ const completeBlocks: string[] = [];
53
+ let currentLines: string[] = [];
54
+ let currentMode: "explicit" | "implicit" | null = null;
55
+ let currentComplete = true;
56
+
57
+ const finalizeCurrent = () => {
58
+ if (currentLines.length === 0) {
59
+ return;
60
+ }
61
+ if (currentMode === "explicit" && currentComplete) {
62
+ completeBlocks.push(currentLines.join("\n"));
63
+ }
64
+ };
65
+
66
+ for (const line of lines) {
67
+ if (startsReasonBlock(line)) {
68
+ finalizeCurrent();
69
+ currentLines = [cleanReasoningLine(line)];
70
+ currentMode = "explicit";
71
+ currentComplete = isClosedReasoningLine(line);
72
+ continue;
73
+ }
74
+
75
+ if (currentLines.length === 0) {
76
+ const trimmedLine = line.trim();
77
+ const cleanedLine = cleanReasoningLine(line);
78
+ if (!cleanedLine) {
79
+ continue;
80
+ }
81
+ if (!trimmedLine.startsWith("_")) {
82
+ continue;
83
+ }
84
+ currentLines = [cleanedLine];
85
+ currentMode = "implicit";
86
+ currentComplete = false;
87
+ continue;
88
+ }
89
+
90
+ currentLines.push(cleanReasoningLine(line));
91
+ currentComplete = currentMode === "explicit"
92
+ ? currentComplete && isClosedReasoningLine(line)
93
+ : false;
94
+ }
95
+
96
+ if (currentLines.length === 0) {
97
+ return {
98
+ completeBlocks,
99
+ pendingBlock: "",
100
+ };
101
+ }
102
+
103
+ if (currentMode === "explicit" && currentComplete) {
104
+ completeBlocks.push(currentLines.join("\n"));
105
+ return {
106
+ completeBlocks,
107
+ pendingBlock: "",
108
+ };
109
+ }
110
+
111
+ return {
112
+ completeBlocks,
113
+ pendingBlock: currentLines.join("\n").trim(),
114
+ };
115
+ }
116
+
117
+ export function createReasoningBlockAssembler(): ReasoningBlockAssembler {
118
+ let emittedBlocks: string[] = [];
119
+ let pendingBlock = "";
120
+
121
+ return {
122
+ ingestSnapshot(text: string | undefined): string[] {
123
+ const parsed = parseReasoningSnapshot(text);
124
+ pendingBlock = parsed.pendingBlock;
125
+
126
+ if (parsed.completeBlocks.length === 0) {
127
+ return [];
128
+ }
129
+
130
+ if (blocksStartWithPrefix(parsed.completeBlocks, emittedBlocks)) {
131
+ const nextBlocks = parsed.completeBlocks.slice(emittedBlocks.length);
132
+ emittedBlocks = [...parsed.completeBlocks];
133
+ return nextBlocks;
134
+ }
135
+
136
+ if (blocksStartWithPrefix(emittedBlocks, parsed.completeBlocks)) {
137
+ return [];
138
+ }
139
+
140
+ return [];
141
+ },
142
+
143
+ flushPendingAtBoundary(): string[] {
144
+ if (!pendingBlock.trim()) {
145
+ return [];
146
+ }
147
+ const flushed = pendingBlock;
148
+ pendingBlock = "";
149
+ return [flushed];
150
+ },
151
+
152
+ reset(): void {
153
+ emittedBlocks = [];
154
+ pendingBlock = "";
155
+ },
156
+ };
157
+ }
@@ -1,4 +1,4 @@
1
- import axios from "axios";
1
+ import axios from "./http-client";
2
2
  import { getProxyBypassOption } from "./utils";
3
3
 
4
4
  const DINGTALK_API = "https://api.dingtalk.com";
@@ -22,14 +22,18 @@ type TimelineEntry = {
22
22
  };
23
23
 
24
24
  export interface CardDraftController {
25
- updateAnswer: (text: string) => Promise<void>;
25
+ updateAnswer: (text: string, options?: { stream?: boolean }) => Promise<void>;
26
26
  updateReasoning: (text: string) => Promise<void>;
27
27
  updateThinking: (text: string) => Promise<void>;
28
+ appendThinkingBlock: (text: string) => Promise<void>;
28
29
  updateTool: (text: string) => Promise<void>;
29
30
  appendTool: (text: string) => Promise<void>;
31
+ appendToolBeforeCurrentAnswer: (text: string) => Promise<void>;
30
32
  /** Signal that a new assistant turn has started (e.g. after a tool call). */
31
33
  notifyNewAssistantTurn: () => Promise<void>;
32
34
  startAssistantTurn: () => Promise<void>;
35
+ /** Seal the active thinking entry (keep it in timeline) without removing it. */
36
+ sealActiveThinking: () => Promise<void>;
33
37
  flush: () => Promise<void>;
34
38
  waitForInFlight: () => Promise<void>;
35
39
  stop: () => void;
@@ -77,6 +81,8 @@ export function createCardDraftController(params: {
77
81
  let failed = false;
78
82
  let stopped = false;
79
83
  let lastSentContent = "";
84
+ let lastQueuedContent = "";
85
+ let inFlightContent = "";
80
86
  let lastAnswerContent = "";
81
87
 
82
88
  let timelineEntries: TimelineEntry[] = [];
@@ -116,6 +122,19 @@ export function createCardDraftController(params: {
116
122
  return timelineEntries.length - 1;
117
123
  };
118
124
 
125
+ const findCurrentSegmentAnswerIndex = (): number | null => {
126
+ return activeAnswerIndex;
127
+ };
128
+
129
+ const findLastAnswerEntryIndex = (): number | null => {
130
+ for (let index = timelineEntries.length - 1; index >= 0; index -= 1) {
131
+ if (timelineEntries[index]?.kind === "answer") {
132
+ return index;
133
+ }
134
+ }
135
+ return null;
136
+ };
137
+
119
138
  const renderTimeline = (options: {
120
139
  fallbackAnswer?: string;
121
140
  overrideAnswer?: string;
@@ -174,13 +193,29 @@ export function createCardDraftController(params: {
174
193
  activeAnswerIndex = null;
175
194
  };
176
195
 
196
+ const clearPendingRender = () => {
197
+ loop.resetPending();
198
+ lastQueuedContent = "";
199
+ };
200
+
177
201
  const queueRender = () => {
178
202
  const rendered = renderTimeline({ compactProcessAnswerSpacing: true });
179
- if (rendered) {
180
- loop.update(rendered);
203
+ if (!rendered) {
204
+ clearPendingRender();
181
205
  return;
182
206
  }
183
- loop.resetPending();
207
+ if (rendered === lastSentContent) {
208
+ const hasNewerInFlight = !!inFlightContent && inFlightContent !== rendered;
209
+ if (!hasNewerInFlight) {
210
+ clearPendingRender();
211
+ return;
212
+ }
213
+ }
214
+ if (rendered === lastQueuedContent) {
215
+ return;
216
+ }
217
+ lastQueuedContent = rendered;
218
+ loop.update(rendered);
184
219
  };
185
220
 
186
221
  const flushBoundaryFrame = async () => {
@@ -215,14 +250,20 @@ export function createCardDraftController(params: {
215
250
  throttleMs: effectiveThrottleMs,
216
251
  isStopped: () => stopped || failed,
217
252
  sendOrEditStreamMessage: async (content: string) => {
253
+ inFlightContent = content;
218
254
  try {
219
255
  await streamAICard(params.card, content, false, params.log);
220
256
  lastSentContent = content;
257
+ lastQueuedContent = "";
221
258
  lastAnswerContent = getFinalAnswerContent();
222
259
  } catch (err: unknown) {
223
260
  failed = true;
224
261
  const message = err instanceof Error ? err.message : String(err);
225
262
  params.log?.warn?.(`[DingTalk][AICard] Stream failed: ${message}`);
263
+ } finally {
264
+ if (inFlightContent === content) {
265
+ inFlightContent = "";
266
+ }
226
267
  }
227
268
  },
228
269
  });
@@ -250,7 +291,7 @@ export function createCardDraftController(params: {
250
291
  queueRender();
251
292
  };
252
293
 
253
- const updateAnswer = async (text: string) => {
294
+ const updateAnswer = async (text: string, options: { stream?: boolean } = {}) => {
254
295
  await waitForPendingBoundary();
255
296
  if (stopped || failed) {
256
297
  return;
@@ -271,6 +312,10 @@ export function createCardDraftController(params: {
271
312
  } else {
272
313
  activeAnswerIndex = appendTimelineEntry("answer", normalized);
273
314
  }
315
+ if (options.stream === false) {
316
+ clearPendingRender();
317
+ return;
318
+ }
274
319
  queueRender();
275
320
  };
276
321
 
@@ -292,6 +337,61 @@ export function createCardDraftController(params: {
292
337
  queueRender();
293
338
  };
294
339
 
340
+ const appendThinkingBlock = async (text: string) => {
341
+ await waitForPendingBoundary();
342
+ if (stopped || failed) {
343
+ return;
344
+ }
345
+ const normalized = normalizeProcessText(text);
346
+ if (!normalized) {
347
+ return;
348
+ }
349
+ if (timelineEntries.length > 0) {
350
+ await flushBoundaryFrame();
351
+ }
352
+ sealLiveThinking();
353
+ const currentSegmentAnswerIndex = findCurrentSegmentAnswerIndex();
354
+ if (currentSegmentAnswerIndex !== null) {
355
+ timelineEntries.splice(currentSegmentAnswerIndex, 0, { kind: "thinking", text: normalized });
356
+ if (activeAnswerIndex !== null && activeAnswerIndex >= currentSegmentAnswerIndex) {
357
+ activeAnswerIndex += 1;
358
+ }
359
+ } else {
360
+ sealCurrentAnswer();
361
+ appendTimelineEntry("thinking", normalized);
362
+ }
363
+ queueRender();
364
+ };
365
+
366
+ const appendToolBeforeCurrentAnswer = async (text: string) => {
367
+ await waitForPendingBoundary();
368
+ if (stopped || failed) {
369
+ return;
370
+ }
371
+ const normalized = normalizeProcessText(text);
372
+ if (!normalized) {
373
+ return;
374
+ }
375
+ if (timelineEntries.length > 0) {
376
+ await flushBoundaryFrame();
377
+ }
378
+ sealLiveThinking();
379
+ const insertionIndex = findCurrentSegmentAnswerIndex() ?? findLastAnswerEntryIndex();
380
+ if (insertionIndex !== null) {
381
+ timelineEntries.splice(insertionIndex, 0, { kind: "tool", text: normalized });
382
+ if (activeAnswerIndex !== null && activeAnswerIndex >= insertionIndex) {
383
+ activeAnswerIndex += 1;
384
+ }
385
+ if (activeThinkingIndex !== null && activeThinkingIndex >= insertionIndex) {
386
+ activeThinkingIndex += 1;
387
+ }
388
+ } else {
389
+ sealCurrentAnswer();
390
+ appendTimelineEntry("tool", normalized);
391
+ }
392
+ queueRender();
393
+ };
394
+
295
395
  const notifyNewAssistantTurn = async () => {
296
396
  if (stopped || failed) {
297
397
  return;
@@ -303,7 +403,7 @@ export function createCardDraftController(params: {
303
403
  }
304
404
  if (activeThinkingIndex !== null) {
305
405
  removeTimelineEntry(activeThinkingIndex);
306
- loop.resetPending();
406
+ clearPendingRender();
307
407
  }
308
408
  };
309
409
 
@@ -311,10 +411,21 @@ export function createCardDraftController(params: {
311
411
  updateAnswer,
312
412
  updateReasoning,
313
413
  updateThinking: updateReasoning,
414
+ appendThinkingBlock,
314
415
  updateTool,
315
416
  appendTool: updateTool,
417
+ appendToolBeforeCurrentAnswer,
316
418
  notifyNewAssistantTurn,
317
419
  startAssistantTurn: notifyNewAssistantTurn,
420
+ sealActiveThinking: async () => {
421
+ if (stopped || failed) {
422
+ return;
423
+ }
424
+ if (activeThinkingIndex !== null) {
425
+ sealLiveThinking();
426
+ await beginBoundaryFlush();
427
+ }
428
+ },
318
429
  flush: () => loop.flush(),
319
430
  waitForInFlight: () => loop.waitForInFlight(),
320
431