@runtypelabs/persona 3.9.2 → 3.10.1

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 (39) hide show
  1. package/dist/index.cjs +45 -42
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +148 -0
  4. package/dist/index.d.ts +148 -0
  5. package/dist/index.global.js +67 -64
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +45 -42
  8. package/dist/index.js.map +1 -1
  9. package/dist/theme-editor.cjs +959 -214
  10. package/dist/theme-editor.d.cts +157 -3
  11. package/dist/theme-editor.d.ts +157 -3
  12. package/dist/theme-editor.js +955 -214
  13. package/dist/theme-reference.cjs +1 -1
  14. package/dist/theme-reference.d.cts +8 -0
  15. package/dist/theme-reference.d.ts +8 -0
  16. package/dist/theme-reference.js +1 -1
  17. package/dist/widget.css +154 -0
  18. package/package.json +1 -1
  19. package/src/client.test.ts +312 -1
  20. package/src/client.ts +247 -24
  21. package/src/components/messages.ts +1 -1
  22. package/src/components/reasoning-bubble.ts +117 -28
  23. package/src/components/tool-bubble.ts +161 -27
  24. package/src/defaults.ts +12 -0
  25. package/src/styles/widget.css +154 -0
  26. package/src/theme-editor/index.ts +5 -0
  27. package/src/theme-editor/preview-utils.test.ts +58 -0
  28. package/src/theme-editor/preview-utils.ts +220 -4
  29. package/src/theme-editor/sections.test.ts +20 -0
  30. package/src/theme-editor/sections.ts +10 -0
  31. package/src/theme-reference.ts +8 -3
  32. package/src/tool-call-display-defaults.test.ts +23 -0
  33. package/src/types.ts +155 -0
  34. package/src/ui.attachments-drop.test.ts +188 -0
  35. package/src/ui.scroll.test.ts +150 -0
  36. package/src/ui.tool-display.test.ts +204 -0
  37. package/src/ui.ts +275 -7
  38. package/src/utils/message-fingerprint.test.ts +17 -0
  39. package/src/utils/message-fingerprint.ts +13 -1
package/src/ui.ts CHANGED
@@ -147,6 +147,19 @@ function getClipboardImageFiles(clipboardData: DataTransfer | null): File[] {
147
147
  return imageFiles;
148
148
  }
149
149
 
150
+ function dataTransferHasFiles(
151
+ dataTransfer: DataTransfer | null
152
+ ): dataTransfer is DataTransfer {
153
+ if (!dataTransfer) return false;
154
+ const types = dataTransfer.types;
155
+ if (!types) return false;
156
+ // Real browsers return DOMStringList which has .contains(); test polyfills use plain arrays.
157
+ if (typeof (types as unknown as { contains?: unknown }).contains === "function") {
158
+ return (types as unknown as DOMStringList).contains("Files");
159
+ }
160
+ return Array.from(types).includes("Files");
161
+ }
162
+
150
163
  // ============================================================================
151
164
  // PERSIST STATE HELPERS
152
165
  // ============================================================================
@@ -389,11 +402,43 @@ const buildPostprocessor = (
389
402
  };
390
403
  };
391
404
 
405
+ function buildDropOverlay(
406
+ dropCfg?: NonNullable<AgentWidgetConfig["attachments"]>["dropOverlay"]
407
+ ): HTMLElement {
408
+ const overlay = createElement("div", "persona-attachment-drop-overlay");
409
+ if (dropCfg?.background) overlay.style.setProperty("--persona-drop-overlay-bg", dropCfg.background);
410
+ if (dropCfg?.backdropBlur !== undefined) overlay.style.setProperty("--persona-drop-overlay-blur", dropCfg.backdropBlur);
411
+ if (dropCfg?.border) overlay.style.setProperty("--persona-drop-overlay-border", dropCfg.border);
412
+ if (dropCfg?.borderRadius) overlay.style.setProperty("--persona-drop-overlay-radius", dropCfg.borderRadius);
413
+ if (dropCfg?.inset) overlay.style.setProperty("--persona-drop-overlay-inset", dropCfg.inset);
414
+ if (dropCfg?.labelSize) overlay.style.setProperty("--persona-drop-overlay-label-size", dropCfg.labelSize);
415
+ if (dropCfg?.labelColor) overlay.style.setProperty("--persona-drop-overlay-label-color", dropCfg.labelColor);
416
+
417
+ const iconName = dropCfg?.iconName ?? "upload";
418
+ const iconSize = dropCfg?.iconSize ?? "48px";
419
+ const iconColor = dropCfg?.iconColor ?? "rgba(59, 130, 246, 0.6)";
420
+ const iconStrokeWidth = dropCfg?.iconStrokeWidth ?? 0.5;
421
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, iconStrokeWidth);
422
+ if (iconSvg) overlay.appendChild(iconSvg);
423
+
424
+ if (dropCfg?.label) {
425
+ const labelEl = createElement("span", "persona-drop-overlay-label");
426
+ labelEl.textContent = dropCfg.label;
427
+ overlay.appendChild(labelEl);
428
+ }
429
+ return overlay;
430
+ }
431
+
392
432
  export const createAgentExperience = (
393
433
  mount: HTMLElement,
394
434
  initialConfig?: AgentWidgetConfig,
395
435
  runtimeOptions?: { debugTools?: boolean }
396
436
  ): Controller => {
437
+ if (mount == null) {
438
+ throw new Error(
439
+ "createAgentExperience: mount must be a non-null HTMLElement (e.g. pass document.getElementById(\"my-root\") after the node exists)."
440
+ );
441
+ }
397
442
  // Preserve original mount id as data attribute for window event instance scoping
398
443
  if (mount.id && !mount.getAttribute("data-persona-instance")) {
399
444
  mount.setAttribute("data-persona-instance", mount.id);
@@ -870,8 +915,21 @@ export const createAgentExperience = (
870
915
  return composerElements.footer;
871
916
  },
872
917
  onSubmit: (text: string) => {
873
- if (session && !session.isStreaming()) {
874
- session.sendMessage(text);
918
+ if (!session || session.isStreaming()) return;
919
+ const value = text.trim();
920
+ const hasAttachments = attachmentManager?.hasAttachments() ?? false;
921
+ if (!value && !hasAttachments) return;
922
+ let contentParts: ContentPart[] | undefined;
923
+ if (hasAttachments) {
924
+ contentParts = [];
925
+ contentParts.push(...attachmentManager!.getContentParts());
926
+ if (value) {
927
+ contentParts.push(createTextPart(value));
928
+ }
929
+ }
930
+ session.sendMessage(value, { contentParts });
931
+ if (hasAttachments) {
932
+ attachmentManager!.clearAttachments();
875
933
  }
876
934
  },
877
935
  streaming: false,
@@ -959,6 +1017,10 @@ export const createAgentExperience = (
959
1017
  attachmentManager?.handleFileSelect(target.files);
960
1018
  target.value = "";
961
1019
  });
1020
+
1021
+ const dropCfg = config.attachments.dropOverlay;
1022
+ const overlay = buildDropOverlay(dropCfg);
1023
+ container.appendChild(overlay);
962
1024
  }
963
1025
 
964
1026
  // Slot system: allow custom content injection into specific regions
@@ -1925,6 +1987,7 @@ export const createAgentExperience = (
1925
1987
  let lastScrollTop = 0;
1926
1988
  let scrollRAF: number | null = null;
1927
1989
  let isAutoScrolling = false;
1990
+ let hasPendingAutoScroll = false;
1928
1991
 
1929
1992
  const USER_SCROLL_THRESHOLD = 1;
1930
1993
  const BOTTOM_THRESHOLD = 8;
@@ -2041,6 +2104,7 @@ export const createAgentExperience = (
2041
2104
  cancelAnimationFrame(scrollRAF);
2042
2105
  scrollRAF = null;
2043
2106
  }
2107
+ hasPendingAutoScroll = false;
2044
2108
  cancelSmoothScroll();
2045
2109
  };
2046
2110
 
@@ -2076,10 +2140,25 @@ export const createAgentExperience = (
2076
2140
 
2077
2141
  if (!force && !isStreaming) return;
2078
2142
 
2079
- cancelAutoScroll();
2143
+ // Only cancel the pending schedule rAF — keep the ongoing smooth scroll
2144
+ // animation alive so isAutoScrolling stays true. This prevents scroll
2145
+ // events fired by DOM morphing (between cancel and the next rAF) from
2146
+ // being misinterpreted as user-initiated upward scrolls that would
2147
+ // permanently pause auto-follow during streaming.
2148
+ // smoothScrollToBottom() already calls cancelSmoothScroll() internally
2149
+ // before starting its new animation.
2150
+ if (scrollRAF !== null) {
2151
+ cancelAnimationFrame(scrollRAF);
2152
+ scrollRAF = null;
2153
+ }
2080
2154
 
2155
+ // Treat the render -> next-rAF window as programmatic scrolling too.
2156
+ // This prevents layout/scroll-anchoring scroll events fired before the
2157
+ // actual smooth scroll starts from being misread as user intent.
2158
+ hasPendingAutoScroll = true;
2081
2159
  scrollRAF = requestAnimationFrame(() => {
2082
2160
  scrollRAF = null;
2161
+ hasPendingAutoScroll = false;
2083
2162
  if (!autoFollow.isFollowing()) return;
2084
2163
  smoothScrollToBottom(getScrollableContainer(), force ? 220 : 140);
2085
2164
  });
@@ -2213,6 +2292,18 @@ export const createAgentExperience = (
2213
2292
  };
2214
2293
 
2215
2294
  const inlineLoadingRenderer = getInlineLoadingIndicatorRenderer();
2295
+ const appendRenderedValue = (
2296
+ containerEl: HTMLElement,
2297
+ value: HTMLElement | string | null | undefined
2298
+ ): boolean => {
2299
+ if (value == null) return false;
2300
+ if (typeof value === "string") {
2301
+ containerEl.textContent = value;
2302
+ return true;
2303
+ }
2304
+ containerEl.appendChild(value);
2305
+ return true;
2306
+ };
2216
2307
 
2217
2308
  // Track active message IDs for cache pruning
2218
2309
  const activeMessageIds = new Set<string>();
@@ -2255,7 +2346,7 @@ export const createAgentExperience = (
2255
2346
  if (!showReasoning) return;
2256
2347
  bubble = matchingPlugin.renderReasoning({
2257
2348
  message,
2258
- defaultRenderer: () => createReasoningBubble(message),
2349
+ defaultRenderer: () => createReasoningBubble(message, config),
2259
2350
  config
2260
2351
  });
2261
2352
  } else if (message.variant === "tool" && message.toolCall && matchingPlugin.renderToolCall) {
@@ -2371,7 +2462,7 @@ export const createAgentExperience = (
2371
2462
  if (!bubble) {
2372
2463
  if (message.variant === "reasoning" && message.reasoning) {
2373
2464
  if (!showReasoning) return;
2374
- bubble = createReasoningBubble(message);
2465
+ bubble = createReasoningBubble(message, config);
2375
2466
  } else if (message.variant === "tool" && message.toolCall) {
2376
2467
  if (!showToolCalls) return;
2377
2468
  bubble = createToolBubble(message, config);
@@ -2428,6 +2519,86 @@ export const createAgentExperience = (
2428
2519
  tempContainer.appendChild(wrapper);
2429
2520
  });
2430
2521
 
2522
+ if (config.features?.toolCallDisplay?.grouped) {
2523
+ const toolGroups: AgentWidgetMessage[][] = [];
2524
+ let currentGroup: AgentWidgetMessage[] = [];
2525
+
2526
+ messages.forEach((message) => {
2527
+ if (message.variant === "tool" && message.toolCall && showToolCalls) {
2528
+ currentGroup.push(message);
2529
+ return;
2530
+ }
2531
+ if (currentGroup.length > 1) {
2532
+ toolGroups.push(currentGroup);
2533
+ }
2534
+ currentGroup = [];
2535
+ });
2536
+ if (currentGroup.length > 1) {
2537
+ toolGroups.push(currentGroup);
2538
+ }
2539
+
2540
+ toolGroups.forEach((group, groupIndex) => {
2541
+ const wrappers = group
2542
+ .map((groupMessage) =>
2543
+ Array.from(tempContainer.children).find(
2544
+ (child) =>
2545
+ child instanceof HTMLElement &&
2546
+ child.getAttribute("data-wrapper-id") === groupMessage.id
2547
+ ) as HTMLElement | undefined
2548
+ )
2549
+ .filter((wrapper): wrapper is HTMLElement => Boolean(wrapper));
2550
+
2551
+ if (wrappers.length < 2) {
2552
+ return;
2553
+ }
2554
+
2555
+ const groupWrapper = document.createElement("div");
2556
+ groupWrapper.className = "persona-flex";
2557
+ groupWrapper.id = `wrapper-tool-group-${groupIndex}-${group[0].id}`;
2558
+ groupWrapper.setAttribute("data-wrapper-id", `tool-group-${groupIndex}-${group[0].id}`);
2559
+
2560
+ const groupContainer = document.createElement("div");
2561
+ groupContainer.className =
2562
+ "persona-tool-group persona-flex persona-w-full persona-flex-col persona-gap-2";
2563
+ groupContainer.setAttribute("data-persona-tool-group", "true");
2564
+
2565
+ const summary = document.createElement("div");
2566
+ summary.className =
2567
+ "persona-tool-group-summary persona-text-xs persona-text-persona-muted";
2568
+
2569
+ const defaultSummary = `Called ${group.length} tools`;
2570
+ const renderedSummary = config.toolCall?.renderGroupedSummary?.({
2571
+ messages: group,
2572
+ toolCalls: group
2573
+ .map((groupMessage) => groupMessage.toolCall)
2574
+ .filter((toolCall): toolCall is NonNullable<typeof group[number]["toolCall"]> => Boolean(toolCall)),
2575
+ defaultSummary,
2576
+ config,
2577
+ });
2578
+ if (!appendRenderedValue(summary, renderedSummary)) {
2579
+ summary.textContent = defaultSummary;
2580
+ }
2581
+
2582
+ const stack = document.createElement("div");
2583
+ stack.className = "persona-tool-group-stack persona-flex persona-flex-col";
2584
+
2585
+ groupContainer.append(summary, stack);
2586
+ groupWrapper.appendChild(groupContainer);
2587
+ wrappers[0].before(groupWrapper);
2588
+
2589
+ wrappers.forEach((wrapper, wrapperIndex) => {
2590
+ const item = document.createElement("div");
2591
+ item.className = "persona-tool-group-item persona-relative";
2592
+ item.setAttribute("data-persona-tool-group-item", "true");
2593
+ if (wrapperIndex < wrappers.length - 1) {
2594
+ item.setAttribute("data-persona-tool-group-connector", "true");
2595
+ }
2596
+ item.appendChild(wrapper);
2597
+ stack.appendChild(item);
2598
+ });
2599
+ });
2600
+ }
2601
+
2431
2602
  // Remove cache entries for messages that no longer exist
2432
2603
  pruneCache(messageCache, activeMessageIds);
2433
2604
 
@@ -3684,7 +3855,7 @@ export const createAgentExperience = (
3684
3855
  lastScrollTop,
3685
3856
  nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3686
3857
  userScrollThreshold: USER_SCROLL_THRESHOLD,
3687
- isAutoScrolling,
3858
+ isAutoScrolling: isAutoScrolling || hasPendingAutoScroll,
3688
3859
  pauseOnUpwardScroll: true,
3689
3860
  pauseWhenAwayFromBottom: false,
3690
3861
  resumeRequiresDownwardScroll: true
@@ -3823,6 +3994,78 @@ export const createAgentExperience = (
3823
3994
  textarea?.addEventListener("keydown", handleInputEnter);
3824
3995
  textarea?.addEventListener("paste", handleInputPaste);
3825
3996
 
3997
+ const ATTACHMENT_DROP_ACTIVE_CLASS = "persona-attachment-drop-active";
3998
+ let attachmentFileDragDepth = 0;
3999
+
4000
+ const clearAttachmentDropVisual = () => {
4001
+ attachmentFileDragDepth = 0;
4002
+ container.classList.remove(ATTACHMENT_DROP_ACTIVE_CLASS);
4003
+ };
4004
+
4005
+ const attachmentDropHandlingActive = (): boolean =>
4006
+ config.attachments?.enabled === true && attachmentManager !== null;
4007
+
4008
+ // Visual highlight tracked on `container` (the chat column).
4009
+ const handleAttachmentDragEnterCapture = (e: DragEvent) => {
4010
+ if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
4011
+ attachmentFileDragDepth++;
4012
+ if (attachmentFileDragDepth === 1) {
4013
+ container.classList.add(ATTACHMENT_DROP_ACTIVE_CLASS);
4014
+ }
4015
+ };
4016
+
4017
+ const handleAttachmentDragLeaveCapture = (e: DragEvent) => {
4018
+ if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
4019
+ attachmentFileDragDepth--;
4020
+ if (attachmentFileDragDepth <= 0) {
4021
+ clearAttachmentDropVisual();
4022
+ }
4023
+ };
4024
+
4025
+ // dragover + drop registered on `mount` so the browser default (open file)
4026
+ // is suppressed across the entire widget surface (artifact pane, gaps, etc.).
4027
+ const handleAttachmentDragOverCapture = (e: DragEvent) => {
4028
+ if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
4029
+ e.preventDefault();
4030
+ e.dataTransfer.dropEffect = "copy";
4031
+ };
4032
+
4033
+ const handleAttachmentDropCapture = (e: DragEvent) => {
4034
+ if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
4035
+ e.preventDefault();
4036
+ e.stopPropagation();
4037
+ clearAttachmentDropVisual();
4038
+ const files = Array.from(e.dataTransfer.files ?? []);
4039
+ if (files.length === 0) return;
4040
+ void attachmentManager!.handleFiles(files);
4041
+ };
4042
+
4043
+ const attachmentDropCapture = true;
4044
+ container.addEventListener("dragenter", handleAttachmentDragEnterCapture, attachmentDropCapture);
4045
+ container.addEventListener("dragleave", handleAttachmentDragLeaveCapture, attachmentDropCapture);
4046
+ mount.addEventListener("dragover", handleAttachmentDragOverCapture, attachmentDropCapture);
4047
+ mount.addEventListener("drop", handleAttachmentDropCapture, attachmentDropCapture);
4048
+
4049
+ // Prevent the browser from navigating to/opening a dropped file anywhere on
4050
+ // the page while this widget instance has attachments enabled. These guards
4051
+ // intentionally skip the `dataTransferHasFiles` check because real OS drags
4052
+ // may expose `dataTransfer.types` as a DOMStringList or restrict access
4053
+ // during certain drag phases. The cost is minimal: we suppress the native
4054
+ // "open file" default for ALL drag-overs while the widget is alive and
4055
+ // attachments are on — text drags into the textarea still work because
4056
+ // element-level handlers are unaffected (we don't stopPropagation here).
4057
+ const ownerDoc = mount.ownerDocument;
4058
+ const handleDocDragOver = (e: DragEvent) => {
4059
+ if (!attachmentDropHandlingActive()) return;
4060
+ e.preventDefault();
4061
+ };
4062
+ const handleDocDrop = (e: DragEvent) => {
4063
+ if (!attachmentDropHandlingActive()) return;
4064
+ e.preventDefault();
4065
+ };
4066
+ ownerDoc.addEventListener("dragover", handleDocDragOver);
4067
+ ownerDoc.addEventListener("drop", handleDocDrop);
4068
+
3826
4069
  destroyCallbacks.push(() => {
3827
4070
  if (composerForm) {
3828
4071
  composerForm.removeEventListener("submit", handleSubmit);
@@ -3831,6 +4074,16 @@ export const createAgentExperience = (
3831
4074
  textarea?.removeEventListener("paste", handleInputPaste);
3832
4075
  });
3833
4076
 
4077
+ destroyCallbacks.push(() => {
4078
+ container.removeEventListener("dragenter", handleAttachmentDragEnterCapture, attachmentDropCapture);
4079
+ container.removeEventListener("dragleave", handleAttachmentDragLeaveCapture, attachmentDropCapture);
4080
+ mount.removeEventListener("dragover", handleAttachmentDragOverCapture, attachmentDropCapture);
4081
+ mount.removeEventListener("drop", handleAttachmentDropCapture, attachmentDropCapture);
4082
+ ownerDoc.removeEventListener("dragover", handleDocDragOver);
4083
+ ownerDoc.removeEventListener("drop", handleDocDrop);
4084
+ clearAttachmentDropVisual();
4085
+ });
4086
+
3834
4087
  destroyCallbacks.push(() => {
3835
4088
  session.cancel();
3836
4089
  });
@@ -3853,6 +4106,10 @@ export const createAgentExperience = (
3853
4106
  const previousColorScheme = config.colorScheme;
3854
4107
  const previousLoadingIndicator = config.loadingIndicator;
3855
4108
  const previousIterationDisplay = config.iterationDisplay;
4109
+ const previousShowReasoning = config.features?.showReasoning;
4110
+ const previousShowToolCalls = config.features?.showToolCalls;
4111
+ const previousToolCallDisplay = config.features?.toolCallDisplay;
4112
+ const previousReasoningDisplay = config.features?.reasoningDisplay;
3856
4113
  config = { ...config, ...nextConfig };
3857
4114
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
3858
4115
  applyFullHeightStyles();
@@ -4093,8 +4350,12 @@ export const createAgentExperience = (
4093
4350
  || config.loadingIndicator?.renderIdle !== previousLoadingIndicator?.renderIdle
4094
4351
  || config.loadingIndicator?.showBubble !== previousLoadingIndicator?.showBubble;
4095
4352
  const iterationDisplayChanged = config.iterationDisplay !== previousIterationDisplay;
4353
+ const featuresChanged = (config.features?.showReasoning ?? true) !== (previousShowReasoning ?? true)
4354
+ || (config.features?.showToolCalls ?? true) !== (previousShowToolCalls ?? true)
4355
+ || JSON.stringify(config.features?.toolCallDisplay) !== JSON.stringify(previousToolCallDisplay)
4356
+ || JSON.stringify(config.features?.reasoningDisplay) !== JSON.stringify(previousReasoningDisplay);
4096
4357
  const messagesConfigChanged = toolCallConfigChanged || messageActionsChanged || layoutMessagesChanged
4097
- || loadingIndicatorChanged || iterationDisplayChanged;
4358
+ || loadingIndicatorChanged || iterationDisplayChanged || featuresChanged;
4098
4359
  if (messagesConfigChanged && session) {
4099
4360
  configVersion++;
4100
4361
  renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
@@ -4858,6 +5119,11 @@ export const createAgentExperience = (
4858
5119
  }
4859
5120
  });
4860
5121
  }
5122
+
5123
+ // Create drop overlay if missing
5124
+ if (!container.querySelector(".persona-attachment-drop-overlay")) {
5125
+ container.appendChild(buildDropOverlay(attachmentsConfig.dropOverlay));
5126
+ }
4861
5127
  } else {
4862
5128
  // Show existing attachment button and update config
4863
5129
  attachmentButtonWrapper.style.display = "";
@@ -4887,6 +5153,8 @@ export const createAgentExperience = (
4887
5153
  if (attachmentManager) {
4888
5154
  attachmentManager.clearAttachments();
4889
5155
  }
5156
+ // Remove drop overlay
5157
+ container.querySelector(".persona-attachment-drop-overlay")?.remove();
4890
5158
  }
4891
5159
 
4892
5160
  // Update send button styling
@@ -90,6 +90,23 @@ describe("computeMessageFingerprint", () => {
90
90
  expect(fp1).not.toBe(fp2);
91
91
  });
92
92
 
93
+ it("changes when toolCall chunks change", () => {
94
+ const fp1 = computeMessageFingerprint(
95
+ makeMessage({ toolCall: { status: "running", chunks: ["Loaded tools"] } }),
96
+ 0
97
+ );
98
+ const fp2 = computeMessageFingerprint(
99
+ makeMessage({
100
+ toolCall: {
101
+ status: "running",
102
+ chunks: ["Loaded tools", "\nFetched platform documentation"],
103
+ },
104
+ }),
105
+ 0
106
+ );
107
+ expect(fp1).not.toBe(fp2);
108
+ });
109
+
93
110
  it("changes when reasoning chunks change", () => {
94
111
  const fp1 = computeMessageFingerprint(makeMessage({ reasoning: { chunks: ["step 1"] } }), 0);
95
112
  const fp2 = computeMessageFingerprint(makeMessage({ reasoning: { chunks: ["step 1", "step 2"] } }), 0);
@@ -16,7 +16,12 @@ export type FingerprintableMessage = {
16
16
  rawContent?: string;
17
17
  llmContent?: string;
18
18
  approval?: { status?: string; [key: string]: unknown };
19
- toolCall?: { status?: string; [key: string]: unknown };
19
+ toolCall?: {
20
+ status?: string;
21
+ chunks?: string[];
22
+ args?: unknown;
23
+ [key: string]: unknown;
24
+ };
20
25
  reasoning?: { chunks?: string[]; status?: string; [key: string]: unknown };
21
26
  contentParts?: unknown[];
22
27
  };
@@ -48,6 +53,13 @@ export function computeMessageFingerprint(
48
53
  message.llmContent?.length ?? 0,
49
54
  message.approval?.status ?? "",
50
55
  message.toolCall?.status ?? "",
56
+ message.toolCall?.chunks?.length ?? 0,
57
+ message.toolCall?.chunks?.[message.toolCall.chunks.length - 1]?.slice(-32) ?? "",
58
+ typeof message.toolCall?.args === "string"
59
+ ? message.toolCall.args.length
60
+ : message.toolCall?.args
61
+ ? JSON.stringify(message.toolCall.args).length
62
+ : 0,
51
63
  message.reasoning?.chunks?.length ?? 0,
52
64
  message.contentParts?.length ?? 0,
53
65
  configVersion,