@agent-native/toolkit 0.10.0 → 0.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 (51) hide show
  1. package/dist/composer/PromptComposer.d.ts +1 -0
  2. package/dist/composer/PromptComposer.d.ts.map +1 -1
  3. package/dist/composer/PromptComposer.js +4 -1
  4. package/dist/composer/PromptComposer.js.map +1 -1
  5. package/dist/composer/PromptComposer.spec.js +8 -1
  6. package/dist/composer/PromptComposer.spec.js.map +1 -1
  7. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
  8. package/dist/composer/RealtimeVoiceMode.js +56 -11
  9. package/dist/composer/RealtimeVoiceMode.js.map +1 -1
  10. package/dist/composer/RealtimeVoiceMode.spec.js +59 -7
  11. package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -1
  12. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  13. package/dist/composer/useRealtimeVoiceMode.js +32 -0
  14. package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
  15. package/dist/composer/useRealtimeVoiceMode.spec.js +25 -2
  16. package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -1
  17. package/dist/conformance/conformance.spec.js +30 -1
  18. package/dist/conformance/conformance.spec.js.map +1 -1
  19. package/dist/conformance/runner.d.ts.map +1 -1
  20. package/dist/conformance/runner.js +75 -1
  21. package/dist/conformance/runner.js.map +1 -1
  22. package/dist/editor/SharedRichEditor.d.ts.map +1 -1
  23. package/dist/editor/SharedRichEditor.js +3 -2
  24. package/dist/editor/SharedRichEditor.js.map +1 -1
  25. package/dist/editor/SlashCommandMenu.d.ts +8 -0
  26. package/dist/editor/SlashCommandMenu.d.ts.map +1 -1
  27. package/dist/editor/SlashCommandMenu.js +6 -0
  28. package/dist/editor/SlashCommandMenu.js.map +1 -1
  29. package/dist/editor/SlashCommandMenu.spec.d.ts +2 -0
  30. package/dist/editor/SlashCommandMenu.spec.d.ts.map +1 -0
  31. package/dist/editor/SlashCommandMenu.spec.js +20 -0
  32. package/dist/editor/SlashCommandMenu.spec.js.map +1 -0
  33. package/dist/editor/index.d.ts +1 -1
  34. package/dist/editor/index.d.ts.map +1 -1
  35. package/dist/editor/index.js +1 -1
  36. package/dist/editor/index.js.map +1 -1
  37. package/dist/styles.css +52 -23
  38. package/package.json +1 -1
  39. package/src/composer/PromptComposer.spec.ts +12 -1
  40. package/src/composer/PromptComposer.tsx +5 -1
  41. package/src/composer/RealtimeVoiceMode.spec.tsx +117 -9
  42. package/src/composer/RealtimeVoiceMode.tsx +119 -31
  43. package/src/composer/useRealtimeVoiceMode.spec.ts +28 -2
  44. package/src/composer/useRealtimeVoiceMode.tsx +30 -0
  45. package/src/conformance/conformance.spec.tsx +61 -1
  46. package/src/conformance/runner.tsx +153 -1
  47. package/src/editor/SharedRichEditor.tsx +13 -2
  48. package/src/editor/SlashCommandMenu.spec.ts +33 -0
  49. package/src/editor/SlashCommandMenu.tsx +20 -0
  50. package/src/editor/index.ts +2 -0
  51. package/src/styles.css +52 -23
@@ -89,9 +89,28 @@ const copy: RealtimeVoiceModeCopy = {
89
89
  describe("RealtimeVoiceMode", () => {
90
90
  let container: HTMLDivElement;
91
91
  let root: Root;
92
+ let animate: ReturnType<typeof vi.fn>;
93
+ let cancelGlowAnimation: ReturnType<typeof vi.fn>;
94
+ let updateGlowPlaybackRate: ReturnType<typeof vi.fn>;
95
+ let originalAnimate: typeof HTMLElement.prototype.animate | undefined;
92
96
 
93
97
  beforeEach(() => {
94
98
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
99
+ originalAnimate = HTMLElement.prototype.animate;
100
+ cancelGlowAnimation = vi.fn();
101
+ updateGlowPlaybackRate = vi.fn();
102
+ animate = vi.fn(
103
+ () =>
104
+ ({
105
+ cancel: cancelGlowAnimation,
106
+ playbackRate: 1,
107
+ updatePlaybackRate: updateGlowPlaybackRate,
108
+ }) as unknown as Animation,
109
+ );
110
+ Object.defineProperty(HTMLElement.prototype, "animate", {
111
+ configurable: true,
112
+ value: animate,
113
+ });
95
114
  container = document.createElement("div");
96
115
  document.body.appendChild(container);
97
116
  root = createRoot(container);
@@ -100,6 +119,14 @@ describe("RealtimeVoiceMode", () => {
100
119
  afterEach(() => {
101
120
  act(() => root.unmount());
102
121
  container.remove();
122
+ if (originalAnimate) {
123
+ Object.defineProperty(HTMLElement.prototype, "animate", {
124
+ configurable: true,
125
+ value: originalAnimate,
126
+ });
127
+ } else {
128
+ Reflect.deleteProperty(HTMLElement.prototype, "animate");
129
+ }
103
130
  vi.unstubAllGlobals();
104
131
  });
105
132
 
@@ -291,22 +318,21 @@ describe("RealtimeVoiceMode", () => {
291
318
  expect(document.body.textContent).toContain("Set up voice mode");
292
319
  const prompt = document.querySelector<HTMLElement>('[role="dialog"]');
293
320
  expect(prompt?.className).toContain(
294
- "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,30rem),30rem)]",
321
+ "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,24rem),24rem)]",
295
322
  );
296
323
  expect(prompt?.dataset.collisionBoundary).toBe("agent-panel");
297
324
  const actions = Array.from(prompt?.querySelectorAll("div") ?? []).find(
298
- (element) => element.className.includes("sm:flex-row"),
325
+ (element) => element.className.includes("bg-muted/30"),
299
326
  );
300
327
  const actionClasses = actions?.className.split(/\s+/) ?? [];
301
- expect(actionClasses).toContain("sm:flex-wrap");
302
- expect(actionClasses).not.toContain("sm:flex-nowrap");
328
+ expect(actionClasses).toContain("grid");
329
+ expect(actionClasses).toContain("rounded-lg");
303
330
  const buttons = Array.from(
304
331
  document.querySelectorAll<HTMLButtonElement>("button"),
305
332
  );
306
333
  expect(
307
- buttons.find((button) => button.textContent === "Connect Builder.io")
308
- ?.className,
309
- ).toContain("whitespace-nowrap");
334
+ buttons.find((button) => button.textContent === "Connect Builder.io"),
335
+ ).toBeDefined();
310
336
  act(() =>
311
337
  buttons
312
338
  .find((button) => button.textContent === "Connect Builder.io")
@@ -766,7 +792,28 @@ describe("RealtimeVoiceMode", () => {
766
792
  },
767
793
  );
768
794
 
769
- it("shines only while the agent is working", () => {
795
+ it.each(["listening", "speaking"] as const)(
796
+ "keeps a soft session glow while %s",
797
+ (state) => {
798
+ render(
799
+ <RealtimeVoiceModeDock
800
+ state={state}
801
+ copy={copy}
802
+ chatVisible={false}
803
+ onToggleChat={vi.fn()}
804
+ onEndVoiceMode={vi.fn()}
805
+ />,
806
+ );
807
+ expect(
808
+ document.querySelector(".agent-realtime-voice-glow"),
809
+ ).not.toBeNull();
810
+ expect(
811
+ document.querySelector(".agent-realtime-voice-working"),
812
+ ).toBeNull();
813
+ },
814
+ );
815
+
816
+ it("brightens the live glow while working", () => {
770
817
  render(
771
818
  <RealtimeVoiceModeDock
772
819
  state="working"
@@ -776,10 +823,14 @@ describe("RealtimeVoiceMode", () => {
776
823
  onEndVoiceMode={vi.fn()}
777
824
  />,
778
825
  );
826
+ expect(document.querySelector(".agent-realtime-voice-glow")).not.toBeNull();
779
827
  expect(
780
828
  document.querySelector(".agent-realtime-voice-working"),
781
829
  ).not.toBeNull();
830
+ });
782
831
 
832
+ it("changes glow speed without restarting or jumping its rotation", () => {
833
+ vi.useFakeTimers();
783
834
  render(
784
835
  <RealtimeVoiceModeDock
785
836
  state="listening"
@@ -789,9 +840,66 @@ describe("RealtimeVoiceMode", () => {
789
840
  onEndVoiceMode={vi.fn()}
790
841
  />,
791
842
  );
792
- expect(document.querySelector(".agent-realtime-voice-working")).toBeNull();
843
+ const glow = document.querySelector('[data-realtime-voice-glow="true"]');
844
+
845
+ expect(animate).toHaveBeenCalledTimes(1);
846
+ expect(updateGlowPlaybackRate).toHaveBeenLastCalledWith(1);
847
+
848
+ render(
849
+ <RealtimeVoiceModeDock
850
+ state="working"
851
+ copy={copy}
852
+ chatVisible={false}
853
+ onToggleChat={vi.fn()}
854
+ onEndVoiceMode={vi.fn()}
855
+ />,
856
+ );
857
+
858
+ expect(document.querySelector('[data-realtime-voice-glow="true"]')).toBe(
859
+ glow,
860
+ );
861
+ expect(animate).toHaveBeenCalledTimes(1);
862
+ expect(cancelGlowAnimation).not.toHaveBeenCalled();
863
+ expect(updateGlowPlaybackRate).toHaveBeenLastCalledWith(2.4);
864
+
865
+ render(
866
+ <RealtimeVoiceModeDock
867
+ state="speaking"
868
+ copy={copy}
869
+ chatVisible={false}
870
+ onToggleChat={vi.fn()}
871
+ onEndVoiceMode={vi.fn()}
872
+ />,
873
+ );
874
+
875
+ expect(animate).toHaveBeenCalledTimes(1);
876
+ expect(cancelGlowAnimation).not.toHaveBeenCalled();
877
+ expect(updateGlowPlaybackRate).toHaveBeenLastCalledWith(2.4);
878
+
879
+ act(() => vi.advanceTimersByTime(800));
880
+ expect(updateGlowPlaybackRate).toHaveBeenLastCalledWith(1);
881
+ vi.useRealTimers();
793
882
  });
794
883
 
884
+ it.each(["connecting", "error", "ending"] as const)(
885
+ "does not show the live glow while %s",
886
+ (state) => {
887
+ render(
888
+ <RealtimeVoiceModeDock
889
+ state={state}
890
+ copy={copy}
891
+ chatVisible={false}
892
+ onToggleChat={vi.fn()}
893
+ onEndVoiceMode={vi.fn()}
894
+ />,
895
+ );
896
+ expect(document.querySelector(".agent-realtime-voice-glow")).toBeNull();
897
+ expect(
898
+ document.querySelector(".agent-realtime-voice-working"),
899
+ ).toBeNull();
900
+ },
901
+ );
902
+
795
903
  it("shows an unmistakable loader and ignores early audio until connected", () => {
796
904
  const audioLevels = createRealtimeVoiceAudioLevelStore();
797
905
  audioLevels.set({ input: 0.8, output: 0 });
@@ -10,10 +10,12 @@ import {
10
10
  import {
11
11
  IconAlertTriangle,
12
12
  IconBrain,
13
+ IconKey,
13
14
  IconLanguage,
14
15
  IconLoader2,
15
16
  IconMicrophone,
16
17
  IconPhoneOff,
18
+ IconPlugConnected,
17
19
  IconSettings,
18
20
  IconVolume,
19
21
  } from "@tabler/icons-react";
@@ -225,72 +227,90 @@ export function RealtimeVoiceModeEntry({
225
227
  collisionPadding={16}
226
228
  data-collision-boundary={collisionBoundary ? "agent-panel" : "viewport"}
227
229
  className={cn(
228
- setupRequired ? "p-4" : "p-3",
230
+ setupRequired ? "p-3.5" : "p-3",
229
231
  setupRequired
230
- ? "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,30rem),30rem)]"
232
+ ? "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,24rem),24rem)]"
231
233
  : "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,18rem),18rem)]",
232
234
  )}
233
235
  aria-labelledby={titleId}
234
236
  aria-describedby={setupRequired ? descriptionId : undefined}
235
237
  >
236
- <div className={cn("grid", setupRequired ? "gap-3" : "gap-2.5")}>
237
- <div className="grid gap-1">
238
- <h2 id={titleId} className="text-sm font-semibold text-foreground">
239
- {setupRequired ? copy.setupTitle : copy.promptTitle}
240
- </h2>
238
+ <div className={cn("grid", setupRequired ? "gap-3.5" : "gap-2.5")}>
239
+ <div
240
+ className={cn(
241
+ setupRequired ? "flex items-start gap-3" : "grid gap-1",
242
+ )}
243
+ >
241
244
  {setupRequired ? (
242
- <p
243
- id={descriptionId}
244
- className="text-sm leading-relaxed text-muted-foreground"
245
- >
246
- {copy.setupDescription}
247
- </p>
245
+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
246
+ <IconMicrophone aria-hidden="true" />
247
+ </div>
248
248
  ) : null}
249
+ <div className="grid gap-1">
250
+ <h2
251
+ id={titleId}
252
+ className="text-sm font-semibold leading-5 text-foreground"
253
+ >
254
+ {setupRequired ? copy.setupTitle : copy.promptTitle}
255
+ </h2>
256
+ {setupRequired ? (
257
+ <p
258
+ id={descriptionId}
259
+ className="text-xs leading-5 text-muted-foreground"
260
+ >
261
+ {copy.setupDescription}
262
+ </p>
263
+ ) : null}
264
+ </div>
249
265
  </div>
250
266
 
251
267
  <div
252
268
  className={cn(
253
- "gap-2",
254
- setupRequired
255
- ? "flex flex-col-reverse sm:flex-row sm:flex-wrap sm:justify-end"
256
- : "grid",
269
+ "grid gap-2",
270
+ setupRequired ? "rounded-lg bg-muted/30 p-1" : null,
257
271
  )}
258
272
  >
259
273
  {setupRequired ? (
260
274
  <>
261
275
  <Button
262
276
  type="button"
263
- variant="ghost"
264
277
  size="sm"
265
- onClick={() => choose("dictation", onKeepDictating)}
278
+ className="w-full justify-start px-3"
279
+ disabled={connectingBuilder}
280
+ onClick={() =>
281
+ choose("realtime", onConnectBuilder ?? onStartVoiceMode)
282
+ }
266
283
  >
267
- {copy.keepDictating}
284
+ {connectingBuilder ? (
285
+ <IconLoader2 className="animate-spin" />
286
+ ) : (
287
+ <IconPlugConnected aria-hidden="true" />
288
+ )}
289
+ {copy.connectBuilder}
268
290
  </Button>
269
291
  <Button
270
292
  type="button"
271
293
  variant="outline"
272
294
  size="sm"
295
+ className="w-full justify-start bg-background px-3"
273
296
  onClick={() =>
274
297
  choose("realtime", onUseOpenAiKey ?? onStartVoiceMode)
275
298
  }
276
299
  >
300
+ <IconKey aria-hidden="true" />
277
301
  {openAiConfigured
278
302
  ? copy.startWithOpenAiKey
279
303
  : copy.useOpenAiKey}
280
304
  </Button>
281
305
  <Button
282
306
  type="button"
307
+ variant="ghost"
283
308
  size="sm"
284
- className="whitespace-nowrap"
285
- disabled={connectingBuilder}
286
- onClick={() =>
287
- choose("realtime", onConnectBuilder ?? onStartVoiceMode)
288
- }
309
+ className="w-full justify-start px-3 text-muted-foreground"
310
+ onClick={() => choose("dictation", onKeepDictating)}
289
311
  >
290
- {connectingBuilder ? (
291
- <IconLoader2 className="animate-spin" />
292
- ) : null}
293
- {copy.connectBuilder}
312
+ <IconMicrophone aria-hidden="true" />
313
+ {copy.keepDictating}
294
314
  </Button>
295
315
  </>
296
316
  ) : (
@@ -382,9 +402,14 @@ export interface RealtimeVoiceModeInlineSettings {
382
402
  const SILENT_AUDIO_LEVELS = createRealtimeVoiceAudioLevelStore();
383
403
  const WAVEFORM_WEIGHTS = [0.55, 0.82, 1, 0.82, 0.55];
384
404
  const AUDIO_ACTIVITY_THRESHOLD = 0.1;
405
+ const WORKING_GLOW_HOLD_MS = 800;
385
406
 
386
407
  function usePrefersReducedMotion(): boolean {
387
- const [reduced, setReduced] = useState(false);
408
+ const [reduced, setReduced] = useState(
409
+ () =>
410
+ typeof window !== "undefined" &&
411
+ Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches),
412
+ );
388
413
  useEffect(() => {
389
414
  if (typeof window === "undefined" || !window.matchMedia) return;
390
415
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
@@ -697,6 +722,9 @@ export function RealtimeVoiceModeDock({
697
722
  const controlsId = useId();
698
723
  const [controlsOpen, setControlsOpen] = useState(false);
699
724
  const [settingsOpen, setSettingsOpen] = useState(false);
725
+ const [workingGlowHeld, setWorkingGlowHeld] = useState(state === "working");
726
+ const glowRef = useRef<HTMLSpanElement>(null);
727
+ const glowAnimationRef = useRef<Animation | null>(null);
700
728
  const selectInteractionRef = useRef(false);
701
729
  const selectInteractionFrameRef = useRef<number | null>(null);
702
730
  const levels = useSyncExternalStore(
@@ -724,6 +752,56 @@ export function RealtimeVoiceModeDock({
724
752
  const errorDetailVisible = state === "error" && Boolean(errorMessage);
725
753
  const controlsVisible = controlsOpen || settingsOpen;
726
754
  const chatPanelTranslation = useChatPanelTranslation(chatVisible);
755
+ const workingGlowActive = state === "working" || workingGlowHeld;
756
+
757
+ useEffect(() => {
758
+ if (state === "working") {
759
+ setWorkingGlowHeld(true);
760
+ return;
761
+ }
762
+ if (!workingGlowHeld) return;
763
+ const timer = window.setTimeout(
764
+ () => setWorkingGlowHeld(false),
765
+ WORKING_GLOW_HOLD_MS,
766
+ );
767
+ return () => window.clearTimeout(timer);
768
+ }, [state, workingGlowHeld]);
769
+
770
+ useEffect(() => {
771
+ if (!connected || reducedMotion || !glowRef.current) {
772
+ glowAnimationRef.current?.cancel();
773
+ glowAnimationRef.current = null;
774
+ return;
775
+ }
776
+
777
+ const animation = glowRef.current.animate(
778
+ [{ transform: "rotate(0turn)" }, { transform: "rotate(1turn)" }],
779
+ {
780
+ duration: 12_000,
781
+ easing: "linear",
782
+ iterations: Number.POSITIVE_INFINITY,
783
+ },
784
+ );
785
+ glowAnimationRef.current = animation;
786
+
787
+ return () => {
788
+ animation.cancel();
789
+ if (glowAnimationRef.current === animation) {
790
+ glowAnimationRef.current = null;
791
+ }
792
+ };
793
+ }, [connected, reducedMotion]);
794
+
795
+ useEffect(() => {
796
+ const animation = glowAnimationRef.current;
797
+ if (!animation) return;
798
+ const playbackRate = workingGlowActive ? 2.4 : 1;
799
+ if (typeof animation.updatePlaybackRate === "function") {
800
+ animation.updatePlaybackRate(playbackRate);
801
+ } else {
802
+ animation.playbackRate = playbackRate;
803
+ }
804
+ }, [workingGlowActive]);
727
805
 
728
806
  const handleSelectOpenChange = useCallback((open: boolean) => {
729
807
  if (selectInteractionFrameRef.current !== null) {
@@ -900,9 +978,19 @@ export function RealtimeVoiceModeDock({
900
978
  className={cn(
901
979
  "relative isolate size-16 overflow-visible rounded-full ring-1 backdrop-blur-xl transition-transform duration-150 ease-out focus-visible:ring-offset-2 active:scale-[0.97] motion-reduce:transition-none",
902
980
  ORB_STATE_CLASSES[state],
903
- state === "working" && "agent-realtime-voice-working",
904
981
  )}
905
982
  >
983
+ {connected ? (
984
+ <span
985
+ ref={glowRef}
986
+ aria-hidden="true"
987
+ data-realtime-voice-glow="true"
988
+ className={cn(
989
+ "agent-realtime-voice-glow",
990
+ workingGlowActive && "agent-realtime-voice-working",
991
+ )}
992
+ />
993
+ ) : null}
906
994
  <span className="relative z-10 flex items-center justify-center">
907
995
  {state === "connecting" ? (
908
996
  <VoiceConnectingIndicator />
@@ -522,7 +522,7 @@ describe("Realtime voice dynamic tool manifests", () => {
522
522
  });
523
523
 
524
524
  describe("extractRealtimeVoiceFunctionCalls", () => {
525
- it("waits for the terminal response event before returning a function call", () => {
525
+ it("returns a function call as soon as its final arguments arrive", () => {
526
526
  expect(
527
527
  extractRealtimeVoiceFunctionCalls({
528
528
  type: "response.function_call_arguments.done",
@@ -530,7 +530,13 @@ describe("extractRealtimeVoiceFunctionCalls", () => {
530
530
  call_id: "call-1",
531
531
  arguments: '{"path":"/inbox"}',
532
532
  }),
533
- ).toEqual([]);
533
+ ).toEqual([
534
+ {
535
+ name: "navigate",
536
+ callId: "call-1",
537
+ argumentsText: '{"path":"/inbox"}',
538
+ },
539
+ ]);
534
540
  });
535
541
 
536
542
  it("falls back to completed function items on response.done", () => {
@@ -559,6 +565,26 @@ describe("extractRealtimeVoiceFunctionCalls", () => {
559
565
  ]);
560
566
  });
561
567
 
568
+ it("accepts a completed function output item as a provider fallback", () => {
569
+ expect(
570
+ extractRealtimeVoiceFunctionCalls({
571
+ type: "response.output_item.done",
572
+ item: {
573
+ type: "function_call",
574
+ name: "navigate",
575
+ call_id: "call-output-item",
576
+ arguments: '{"path":"/todos"}',
577
+ },
578
+ }),
579
+ ).toEqual([
580
+ {
581
+ name: "navigate",
582
+ callId: "call-output-item",
583
+ argumentsText: '{"path":"/todos"}',
584
+ },
585
+ ]);
586
+ });
587
+
562
588
  it("ignores function items from failed or cancelled responses", () => {
563
589
  const output = [
564
590
  {
@@ -1005,6 +1005,36 @@ export function createRealtimeVoiceToolManifestCoordinator(
1005
1005
  export function extractRealtimeVoiceFunctionCalls(
1006
1006
  event: RealtimeServerEvent,
1007
1007
  ): Array<{ name: string; callId: string; argumentsText: string }> {
1008
+ if (event.type === "response.function_call_arguments.done") {
1009
+ const name = typeof event.name === "string" ? event.name : "";
1010
+ const callId = typeof event.call_id === "string" ? event.call_id : "";
1011
+ if (!name || !callId) return [];
1012
+ return [
1013
+ {
1014
+ name,
1015
+ callId,
1016
+ argumentsText:
1017
+ typeof event.arguments === "string" ? event.arguments : "{}",
1018
+ },
1019
+ ];
1020
+ }
1021
+ if (event.type === "response.output_item.done") {
1022
+ const item = event.item;
1023
+ if (!item || typeof item !== "object") return [];
1024
+ const record = item as Record<string, unknown>;
1025
+ if (record.type !== "function_call") return [];
1026
+ const name = typeof record.name === "string" ? record.name : "";
1027
+ const callId = typeof record.call_id === "string" ? record.call_id : "";
1028
+ if (!name || !callId) return [];
1029
+ return [
1030
+ {
1031
+ name,
1032
+ callId,
1033
+ argumentsText:
1034
+ typeof record.arguments === "string" ? record.arguments : "{}",
1035
+ },
1036
+ ];
1037
+ }
1008
1038
  if (event.type !== "response.done") return [];
1009
1039
  const response = event.response;
1010
1040
  if (!response || typeof response !== "object") return [];
@@ -3,7 +3,10 @@
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import { defaultDesignSystemComponents } from "../design-system/default-adapter.js";
6
- import { DESIGN_SYSTEM_CONTRACT_VERSION } from "../design-system/types.js";
6
+ import {
7
+ DESIGN_SYSTEM_CONTRACT_VERSION,
8
+ type DesignSystemComponents,
9
+ } from "../design-system/types.js";
7
10
  import { cssInJsFixtureAdapter } from "./__fixtures__/css-in-js-adapter.js";
8
11
  import {
9
12
  assertDesignSystemConformance,
@@ -80,4 +83,61 @@ describe("design-system conformance kit", () => {
80
83
  });
81
84
  expect(report.results.filter((result) => !result.passed)).toEqual([]);
82
85
  });
86
+
87
+ it("rejects tabs that stack an icon above its label", async () => {
88
+ const stackedTabsAdapter: DesignSystemComponents = {
89
+ ...cssInJsFixtureAdapter,
90
+ Tabs: ({ items, value, onChange, orientation }) => (
91
+ <div>
92
+ <div role="tablist" aria-orientation={orientation}>
93
+ {items.map((item) => (
94
+ <button
95
+ key={String(item.value)}
96
+ role="tab"
97
+ aria-selected={item.value === value}
98
+ style={{ display: "flex", flexDirection: "column" }}
99
+ onClick={() => onChange(item.value)}
100
+ >
101
+ {item.icon}
102
+ {item.label}
103
+ </button>
104
+ ))}
105
+ </div>
106
+ {items.find((item) => item.value === value)?.content}
107
+ </div>
108
+ ),
109
+ };
110
+ const report = await runDesignSystemConformance({
111
+ adapterName: "stacked-tabs fixture",
112
+ components: stackedTabsAdapter,
113
+ contractVersion: DESIGN_SYSTEM_CONTRACT_VERSION,
114
+ });
115
+ const tabsResult = report.results.find(
116
+ (result) => result.id === "behavior.tabs",
117
+ );
118
+ expect(tabsResult?.passed).toBe(false);
119
+ expect(tabsResult?.message).toContain("same row");
120
+ });
121
+
122
+ it("rejects dialogs that render footer controls outside the dialog", async () => {
123
+ const detachedFooterAdapter: DesignSystemComponents = {
124
+ ...cssInJsFixtureAdapter,
125
+ Dialog: ({ footer, ...props }) => (
126
+ <>
127
+ <cssInJsFixtureAdapter.Dialog {...props} />
128
+ {footer}
129
+ </>
130
+ ),
131
+ };
132
+ const report = await runDesignSystemConformance({
133
+ adapterName: "detached-footer fixture",
134
+ components: detachedFooterAdapter,
135
+ contractVersion: DESIGN_SYSTEM_CONTRACT_VERSION,
136
+ });
137
+ const dialogResult = report.results.find(
138
+ (result) => result.id === "behavior.dialog",
139
+ );
140
+ expect(dialogResult?.passed).toBe(false);
141
+ expect(dialogResult?.message).toContain("footer");
142
+ });
83
143
  });