@assistant-ui/react-ag-ui 0.0.65 → 0.0.66

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.
@@ -40,6 +40,47 @@ const appendMessage = (): AppendMessage => ({
40
40
  metadata: { custom: {} },
41
41
  });
42
42
 
43
+ describe("toAgUiMessages data URLs", () => {
44
+ it.each(["application/pdf", ""])(
45
+ "unwraps a media-less data URL while retaining the adapter's %j MIME fallback",
46
+ (mimeType) => {
47
+ expect(
48
+ contentOf(
49
+ userMessage([
50
+ { type: "file", data: "data:;base64,SGVsbG8=", mimeType },
51
+ ]),
52
+ ),
53
+ ).toEqual([
54
+ {
55
+ type: "document",
56
+ source: {
57
+ type: "data",
58
+ value: "SGVsbG8=",
59
+ mimeType: mimeType || "application/octet-stream",
60
+ },
61
+ },
62
+ ]);
63
+ },
64
+ );
65
+
66
+ it("sniffs a media-less image data URL", () => {
67
+ expect(
68
+ contentOf(
69
+ userMessage([{ type: "image", image: "data:;base64,iVBORw0KGgo=" }]),
70
+ ),
71
+ ).toEqual([
72
+ {
73
+ type: "image",
74
+ source: {
75
+ type: "data",
76
+ value: "iVBORw0KGgo=",
77
+ mimeType: "image/png",
78
+ },
79
+ },
80
+ ]);
81
+ });
82
+ });
83
+
43
84
  describe("toAgUiMessages content metadata", () => {
44
85
  it("emits AgUiMessage values assignable to AG-UI Message", () => {
45
86
  expectTypeOf<AgUiMessage>().toExtend<AgUiWireMessage>();
@@ -11,6 +11,7 @@ import {
11
11
  getAutoStatus,
12
12
  parseDataUrl,
13
13
  resolveFilePartSource,
14
+ resolveImageMediaType,
14
15
  walkToolCallTree,
15
16
  } from "@assistant-ui/core/internal";
16
17
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
@@ -21,14 +22,13 @@ import {
21
22
  MCP_APPS_ACTIVITY_TYPE,
22
23
  type AgUiCustomMetadata,
23
24
  type AgUiOpaqueReasoning,
24
- } from "./run-aggregator";
25
+ } from "../types";
25
26
  import {
26
27
  applyA2uiOperations,
27
- convertSurfaceToUISpec,
28
- surfaceToOperations,
28
+ surfaceToPresentToolCall,
29
29
  type A2uiState,
30
30
  type A2uiSurfaceState,
31
- } from "@assistant-ui/react-generative-ui/a2ui";
31
+ } from "@assistant-ui/generative-ui/a2ui";
32
32
  import type { AgUiInterrupt } from "../types";
33
33
  import { projectAgUiToolApprovals } from "./tool-approval";
34
34
  import {
@@ -105,6 +105,7 @@ type CoreToolCallPart = Extract<
105
105
  type ToolCallPart = Omit<CoreToolCallPart, "result" | "isError"> & {
106
106
  result?: unknown;
107
107
  isError?: boolean | undefined;
108
+ /** @deprecated Experimental since 2026-03-08. Not scheduled for removal; the API may change in any release. */
108
109
  unstable_toolMessageId?: string;
109
110
  };
110
111
 
@@ -276,9 +277,16 @@ function toInputContent(
276
277
  const image = getString(part, "image");
277
278
  if (image === undefined) return null;
278
279
  const metadata = buildInputMetadata(part, getString(part, "filename"));
280
+ const source = buildInputSource(image, fallbackMimeType);
279
281
  return {
280
282
  type: "image",
281
- source: buildInputSource(image, fallbackMimeType),
283
+ source:
284
+ source.type === "data"
285
+ ? {
286
+ ...source,
287
+ mimeType: resolveImageMediaType(image, fallbackMimeType),
288
+ }
289
+ : source,
282
290
  ...(metadata && { metadata }),
283
291
  };
284
292
  }
@@ -502,7 +510,6 @@ function toToolCallPart(value: unknown): ToolCallPart | null {
502
510
  : isObject(value.args) && !Array.isArray(value.args)
503
511
  ? (value.args as ReadonlyJSONObject)
504
512
  : undefined;
505
-
506
513
  const part: ToolCallPart = {
507
514
  type: "tool-call",
508
515
  ...(toolCallId !== undefined ? { toolCallId } : {}),
@@ -693,27 +700,17 @@ function toUserOrSystemSnapshotMessage(
693
700
  };
694
701
  }
695
702
 
696
- // Rebuilds the a2ui:<surfaceId> "present" tool-call parts for one owning
697
- // assistant message from a bucket of rebuilt surface state, mirroring the
698
- // live RunAggregator.synthesizeA2uiToolCalls shape. Non-a2ui parts (text,
699
- // other tool calls) are preserved; existing a2ui parts are replaced wholesale
700
- // so create/update/delete within the bucket all converge on the rebuilt set.
701
703
  function attachA2uiSurfaces(
702
704
  message: CoreThreadMessageLike,
703
705
  state: A2uiState,
704
706
  ): CoreThreadMessageLike {
705
707
  const a2uiParts: ToolCallPart[] = [];
706
708
  for (const [surfaceId, surface] of state) {
707
- const { spec } = convertSurfaceToUISpec(surface);
708
- if (!spec) continue;
709
+ const { toolCall } = surfaceToPresentToolCall(surfaceId, surface);
710
+ if (!toolCall) continue;
709
711
  a2uiParts.push({
710
712
  type: "tool-call",
711
- toolCallId: `a2ui:${surfaceId}`,
712
- toolName: "present",
713
- args: spec as unknown as ReadonlyJSONObject,
714
- argsText: JSON.stringify(spec),
715
- result: {},
716
- artifact: { a2ui: surfaceToOperations(surface) },
713
+ ...toolCall,
717
714
  });
718
715
  }
719
716
 
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ChatModelRunResult } from "@assistant-ui/core";
3
+ import { RunAggregator } from "./run-aggregator";
4
+ import type { AgUiCustomMetadata } from "../types";
5
+ import { toAgUiMessages } from "./conversions";
6
+
7
+ describe.each([true, false])(
8
+ "reasoning ownership (showThinking=%s)",
9
+ (showThinking) => {
10
+ it.each([
11
+ { parentText: "", nextReasoning: false },
12
+ { parentText: "", nextReasoning: true },
13
+ { parentText: "Looking it up.", nextReasoning: false },
14
+ { parentText: "Looking it up.", nextReasoning: true },
15
+ ])(
16
+ "keeps pre-tool reasoning on its parent ($parentText, nextReasoning=$nextReasoning)",
17
+ ({ parentText, nextReasoning }) => {
18
+ let latest: ChatModelRunResult = {};
19
+ let previous: ChatModelRunResult | undefined;
20
+ const aggregator = new RunAggregator({
21
+ showThinking,
22
+ logger: { debug: () => {}, error: () => {} },
23
+ emit: (update) => {
24
+ latest = update;
25
+ },
26
+ onTextMessageStart: () => {
27
+ previous = latest;
28
+ },
29
+ });
30
+ const reason = (messageId: string, delta: string) => {
31
+ aggregator.handle({ type: "REASONING_MESSAGE_START", messageId });
32
+ aggregator.handle({
33
+ type: "REASONING_MESSAGE_CONTENT",
34
+ messageId,
35
+ delta,
36
+ });
37
+ aggregator.handle({ type: "REASONING_MESSAGE_END", messageId });
38
+ aggregator.handle({
39
+ type: "REASONING_ENCRYPTED_VALUE",
40
+ subtype: "message",
41
+ entityId: messageId,
42
+ encryptedValue: `sig-${messageId}`,
43
+ });
44
+ };
45
+ const reasoningOf = (result: ChatModelRunResult | undefined) =>
46
+ result?.content?.flatMap((part) =>
47
+ part.type === "reasoning" ? [part.text] : [],
48
+ );
49
+ const opaqueOf = (result: ChatModelRunResult | undefined) =>
50
+ (result?.metadata?.custom?.agui as AgUiCustomMetadata | undefined)
51
+ ?.opaqueReasoning ?? [];
52
+
53
+ aggregator.handle({ type: "RUN_STARTED", runId: "run-1" });
54
+ aggregator.handle({
55
+ type: "TEXT_MESSAGE_START",
56
+ messageId: "assistant-1",
57
+ });
58
+ if (parentText)
59
+ aggregator.handle({
60
+ type: "TEXT_MESSAGE_CONTENT",
61
+ messageId: "assistant-1",
62
+ delta: parentText,
63
+ });
64
+ reason("r-1", "plan the call");
65
+ aggregator.handle({
66
+ type: "TOOL_CALL_START",
67
+ toolCallId: "call-1",
68
+ toolCallName: "lookup",
69
+ parentMessageId: "assistant-1",
70
+ });
71
+ aggregator.handle({ type: "TOOL_CALL_END", toolCallId: "call-1" });
72
+ aggregator.handle({
73
+ type: "TOOL_CALL_RESULT",
74
+ toolCallId: "call-1",
75
+ messageId: "tool-1",
76
+ content: "ok",
77
+ });
78
+ if (nextReasoning) reason("r-2", "write the answer");
79
+ aggregator.handle({
80
+ type: "TEXT_MESSAGE_START",
81
+ messageId: "assistant-2",
82
+ });
83
+ aggregator.handle({
84
+ type: "TEXT_MESSAGE_CONTENT",
85
+ messageId: "assistant-2",
86
+ delta: "Done.",
87
+ });
88
+ aggregator.handle({ type: "RUN_FINISHED", runId: "run-1" });
89
+
90
+ expect(
91
+ previous?.content?.filter((part) => part.type === "tool-call"),
92
+ ).toHaveLength(1);
93
+ expect(
94
+ latest.content?.filter((part) => part.type === "tool-call"),
95
+ ).toHaveLength(0);
96
+ if (showThinking) {
97
+ expect(reasoningOf(previous)).toEqual(["plan the call"]);
98
+ expect(reasoningOf(latest)).toEqual(
99
+ nextReasoning ? ["write the answer"] : [],
100
+ );
101
+ } else {
102
+ expect(opaqueOf(previous)).toEqual([
103
+ { id: "r-1", encryptedValue: "sig-r-1" },
104
+ ]);
105
+ expect(opaqueOf(latest)).toEqual(
106
+ nextReasoning ? [{ id: "r-2", encryptedValue: "sig-r-2" }] : [],
107
+ );
108
+ }
109
+ const exported = toAgUiMessages([
110
+ {
111
+ id: "assistant-1",
112
+ role: "assistant",
113
+ content: previous?.content ?? [],
114
+ metadata: previous?.metadata,
115
+ },
116
+ {
117
+ id: "assistant-2",
118
+ role: "assistant",
119
+ content: latest.content ?? [],
120
+ metadata: latest.metadata,
121
+ },
122
+ ]);
123
+ expect(exported.map((message) => message.id)).toEqual([
124
+ "r-1",
125
+ "assistant-1",
126
+ "tool-1",
127
+ ...(nextReasoning ? ["r-2"] : []),
128
+ "assistant-2",
129
+ ]);
130
+ expect(
131
+ exported.flatMap((message) =>
132
+ message.role === "reasoning"
133
+ ? [[message.id, message.encryptedValue]]
134
+ : [],
135
+ ),
136
+ ).toEqual([
137
+ ["r-1", "sig-r-1"],
138
+ ...(nextReasoning ? [["r-2", "sig-r-2"]] : []),
139
+ ]);
140
+ },
141
+ );
142
+ },
143
+ );
@@ -13,19 +13,24 @@ import {
13
13
  } from "@assistant-ui/core";
14
14
  import {
15
15
  applyA2uiOperations,
16
- convertSurfaceToUISpec,
17
- surfaceToOperations,
16
+ surfaceToPresentToolCall,
18
17
  type A2uiState,
19
18
  type A2uiSurfaceState,
20
- } from "@assistant-ui/react-generative-ui/a2ui";
19
+ } from "@assistant-ui/generative-ui/a2ui";
21
20
  import jsonpatch, { type Operation } from "fast-json-patch";
22
21
  import { readMcpAppResourceUri } from "../mcp-tool-result";
23
22
  import { projectAgUiToolApprovals } from "./tool-approval";
24
- import type { AgUiEvent, AgUiInterrupt } from "../types";
23
+ import {
24
+ AG_UI_METADATA_NAMESPACE,
25
+ A2UI_SURFACE_ACTIVITY_TYPE,
26
+ MCP_APPS_ACTIVITY_TYPE,
27
+ type AgUiCustomMetadata,
28
+ type AgUiEvent,
29
+ type AgUiInterrupt,
30
+ type AgUiOpaqueReasoning,
31
+ } from "../types";
25
32
  import type { Logger } from "../logger";
26
33
 
27
- export const AG_UI_METADATA_NAMESPACE = "agui";
28
-
29
34
  const ROOT_SCOPE = "";
30
35
 
31
36
  // Defensive guard against a malformed or cyclic parentToolCallId /
@@ -70,20 +75,6 @@ type BuildContext = {
70
75
  };
71
76
  };
72
77
 
73
- export type AgUiOpaqueReasoning = {
74
- id: string;
75
- encryptedValue: string;
76
- after?: boolean;
77
- };
78
-
79
- export type AgUiCustomMetadata = {
80
- /** Wire role restored on export for messages the internal model cannot
81
- * represent (a developer record rides as a system message). */
82
- role?: "developer";
83
- interrupts?: AgUiInterrupt[];
84
- opaqueReasoning?: AgUiOpaqueReasoning[];
85
- };
86
-
87
78
  type Emit = (update: ChatModelRunResult) => void;
88
79
 
89
80
  type ToolCallState = {
@@ -178,10 +169,6 @@ const scanJSONContainerDelta = (
178
169
  return completed && scanner.state === "complete";
179
170
  };
180
171
 
181
- export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
182
-
183
- export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
184
-
185
172
  export const isPlainObject = (
186
173
  value: unknown,
187
174
  ): value is Record<string, unknown> =>
@@ -259,6 +246,7 @@ export class RunAggregator {
259
246
  >();
260
247
  private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
261
248
  private readonly partOrder: PartOrderEntry[] = [];
249
+ private lastReasoningBeforeToolCall: PartOrderEntry | undefined;
262
250
  private textPartCounter = 0;
263
251
  private serverMessageIdReported = false;
264
252
  private reportedServerMessageId: string | undefined;
@@ -748,23 +736,27 @@ export class RunAggregator {
748
736
 
749
737
  const activeToolCallIds = new Set<string>();
750
738
  for (const [surfaceId, surface] of surfaces) {
751
- const toolCallId = `a2ui:${surfaceId}`;
752
- const { spec, warnings } = convertSurfaceToUISpec(surface);
739
+ const { toolCall, warnings } = surfaceToPresentToolCall(
740
+ surfaceId,
741
+ surface,
742
+ );
753
743
  for (const warning of warnings) {
754
744
  this.logger.debug("[agui] a2ui surface conversion warning", warning);
755
745
  }
756
- if (!spec) continue;
746
+ if (!toolCall) continue;
747
+
748
+ const { toolCallId } = toolCall;
757
749
 
758
750
  activeToolCallIds.add(toolCallId);
759
751
 
760
752
  const entry: ToolCallState = {
761
- toolCallId,
762
- toolCallName: "present",
763
- argsText: JSON.stringify(spec),
764
- parsedArgs: spec,
765
- result: {},
753
+ toolCallId: toolCall.toolCallId,
754
+ toolCallName: toolCall.toolName,
755
+ argsText: toolCall.argsText,
756
+ parsedArgs: toolCall.args,
757
+ result: toolCall.result,
766
758
  isError: undefined,
767
- artifact: { a2ui: surfaceToOperations(surface) },
759
+ artifact: toolCall.artifact,
768
760
  snapshotResultApplied: false,
769
761
  };
770
762
  if (!this.toolCalls.has(toolCallId)) {
@@ -826,6 +818,7 @@ export class RunAggregator {
826
818
  this.activityParts.clear();
827
819
  this.lastResolvedToolCallIdByScope.clear();
828
820
  this.partOrder.length = 0;
821
+ this.lastReasoningBeforeToolCall = undefined;
829
822
  this.textPartCounter = 0;
830
823
  this.activeTextMessageIdByScope.clear();
831
824
  this.reportedServerMessageId = undefined;
@@ -859,8 +852,103 @@ export class RunAggregator {
859
852
  ) {
860
853
  return;
861
854
  }
855
+ const carried = this.takeTrailingReasoning();
856
+ if (carried.parts.length > 0 || carried.hiddenSignatures.length > 0) {
857
+ this.emit();
858
+ }
859
+ const reasoningPartCounter = this.reasoningPartCounter;
862
860
  this.resetMessageParts();
863
861
  this.onTextMessageStart(messageId);
862
+ this.reasoningPartCounter = reasoningPartCounter;
863
+ this.restoreReasoning(carried);
864
+ }
865
+
866
+ private takeTrailingReasoning() {
867
+ const parts: {
868
+ key: string;
869
+ entry: { buffer: string; timing?: MessagePartTiming };
870
+ signature: string | undefined;
871
+ signatureId: string | undefined;
872
+ reasoningId: string | undefined;
873
+ anonymous: boolean;
874
+ }[] = [];
875
+ let last = this.partOrder.at(-1);
876
+ while (
877
+ last?.kind === "reasoning" &&
878
+ last.subagentRunId === undefined &&
879
+ last !== this.lastReasoningBeforeToolCall
880
+ ) {
881
+ this.partOrder.pop();
882
+ parts.unshift({
883
+ key: last.key,
884
+ entry: this.reasoningParts.get(last.key) ?? { buffer: "" },
885
+ signature: this.reasoningSignatures.get(last.key),
886
+ signatureId: this.reasoningSignatureIds.get(last.key),
887
+ reasoningId: this.reasoningMessageIds.get(last.key),
888
+ anonymous: this.anonymousReasoningKeys.has(last.key),
889
+ });
890
+ last = this.partOrder.at(-1);
891
+ }
892
+ const cut = this.partOrder.length;
893
+ const hiddenSignatures = Array.from(this.hiddenSignatures).filter(
894
+ ([id]) => this.hiddenSignatureAnchors.get(id)! >= cut,
895
+ );
896
+ for (const [id] of hiddenSignatures) {
897
+ this.hiddenSignatures.delete(id);
898
+ this.hiddenSignatureAnchors.delete(id);
899
+ }
900
+ const hiddenBlockIds = Array.from(this.hiddenBlockAnchors)
901
+ .filter(([, anchor]) => anchor >= cut)
902
+ .map(([id]) => id);
903
+ const hiddenAnonymous =
904
+ this.hiddenAnonymousAnchor !== undefined &&
905
+ this.hiddenAnonymousAnchor >= cut;
906
+ const activeCarried =
907
+ (this.hiddenActiveReasoning === "identified" &&
908
+ hiddenBlockIds.length > 0) ||
909
+ (this.hiddenActiveReasoning === "anonymous" && hiddenAnonymous);
910
+ return {
911
+ parts,
912
+ activeKey: this.activeReasoningKeyByScope.get(ROOT_SCOPE),
913
+ hiddenSignatures,
914
+ hiddenBlockIds,
915
+ hiddenAnonymous,
916
+ hiddenActiveReasoning: activeCarried
917
+ ? this.hiddenActiveReasoning
918
+ : ("none" as const),
919
+ };
920
+ }
921
+
922
+ private restoreReasoning(
923
+ carried: ReturnType<RunAggregator["takeTrailingReasoning"]>,
924
+ ): void {
925
+ for (const part of carried.parts) {
926
+ this.partOrder.push({ kind: "reasoning", key: part.key });
927
+ this.reasoningParts.set(part.key, part.entry);
928
+ if (part.signature !== undefined)
929
+ this.reasoningSignatures.set(part.key, part.signature);
930
+ if (part.signatureId !== undefined)
931
+ this.reasoningSignatureIds.set(part.key, part.signatureId);
932
+ if (part.reasoningId !== undefined)
933
+ this.reasoningMessageIds.set(part.key, part.reasoningId);
934
+ if (part.anonymous) this.anonymousReasoningKeys.add(part.key);
935
+ }
936
+ if (
937
+ carried.activeKey !== undefined &&
938
+ this.reasoningParts.has(carried.activeKey)
939
+ ) {
940
+ this.activeReasoningKeyByScope.set(ROOT_SCOPE, carried.activeKey);
941
+ }
942
+ for (const [id, encryptedValue] of carried.hiddenSignatures) {
943
+ this.hiddenSignatures.set(id, encryptedValue);
944
+ this.hiddenSignatureAnchors.set(id, 0);
945
+ }
946
+ for (const id of carried.hiddenBlockIds) {
947
+ this.hiddenReasoningIds.add(id);
948
+ this.hiddenBlockAnchors.set(id, 0);
949
+ }
950
+ if (carried.hiddenAnonymous) this.hiddenAnonymousAnchor = 0;
951
+ this.hiddenActiveReasoning = carried.hiddenActiveReasoning;
864
952
  }
865
953
 
866
954
  private generateTextKey(): string {
@@ -932,6 +1020,13 @@ export class RunAggregator {
932
1020
  (part) => part.kind === "tool-call" && part.toolCallId === id,
933
1021
  )
934
1022
  ) {
1023
+ // Tool insertion can precede existing reasoning in display order.
1024
+ if (scope === ROOT_SCOPE) {
1025
+ this.lastReasoningBeforeToolCall = this.partOrder.findLast(
1026
+ (part) =>
1027
+ part.kind === "reasoning" && part.subagentRunId === undefined,
1028
+ );
1029
+ }
935
1030
  this.insertToolPart(scope, id, parentMessageId);
936
1031
  }
937
1032
  const state: ToolCallState = {
@@ -15,11 +15,29 @@ import type { Logger } from "./logger";
15
15
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
16
16
  import type { McpToolCallResult } from "./mcp-tool-result";
17
17
 
18
+ export const AG_UI_METADATA_NAMESPACE = "agui";
19
+ export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
20
+ export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
21
+
22
+ export type AgUiOpaqueReasoning = {
23
+ id: string;
24
+ encryptedValue: string;
25
+ after?: boolean;
26
+ };
27
+
28
+ export type AgUiCustomMetadata = {
29
+ /** Wire role restored on export for messages the internal model cannot
30
+ * represent (a developer record rides as a system message). */
31
+ role?: "developer";
32
+ interrupts?: AgUiInterrupt[];
33
+ opaqueReasoning?: AgUiOpaqueReasoning[];
34
+ };
35
+
18
36
  /**
19
- * @experimental This API is still under active development and might change without notice.
20
- *
21
37
  * Same as ExternalStoreThreadListAdapter, except `onSwitchToThread` returns
22
38
  * the messages (and optional state) to hydrate the thread with.
39
+ *
40
+ * @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release.
23
41
  */
24
42
  type SwitchToThreadResult = {
25
43
  messages: readonly ThreadMessage[];
@@ -28,6 +46,8 @@ type SwitchToThreadResult = {
28
46
  * Set when the thread has a run in flight. The runtime resumes the run
29
47
  * after hydrating, the same way `ThreadHistoryAdapter.load()` does when it
30
48
  * returns `unstable_resume: true`.
49
+ *
50
+ * @deprecated Experimental since 2025-03-31. Not scheduled for removal; the API may change in any release.
31
51
  */
32
52
  unstable_resume?: boolean;
33
53
  };
@@ -50,9 +70,7 @@ export type UseAgUiRuntimeAdapters = {
50
70
  voice?: RealtimeVoiceAdapter;
51
71
  feedback?: FeedbackAdapter;
52
72
  history?: ThreadHistoryAdapter;
53
- /**
54
- * @experimental This API is still under active development and might change without notice.
55
- */
73
+ /** @deprecated Experimental since 2026-01-28. Not scheduled for removal; the API may change in any release. */
56
74
  threadList?: UseAgUiThreadListAdapter;
57
75
  };
58
76
 
@@ -102,6 +120,8 @@ export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
102
120
  * settles, exposing it on `composer.queue` for `ComposerPrimitive.Queue`.
103
121
  * The runtime owns the queue lifecycle because flushing needs the agent's
104
122
  * send path and the run's own busy and idle edges.
123
+ *
124
+ * @deprecated Experimental since 2026-06-04. Not scheduled for removal; the API may change in any release.
105
125
  */
106
126
  unstable_enableMessageQueue?: boolean | undefined;
107
127
  onError?: (e: Error) => void;
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, renderHook } from "@testing-library/react";
4
+ import type { HttpAgent } from "@ag-ui/client";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
+ import { useAgUiRuntime } from "./useAgUiRuntime";
7
+
8
+ afterEach(cleanup);
9
+
10
+ it("exposes the deprecated interrupt methods on the runtime facade", () => {
11
+ const agent = {
12
+ runAgent: vi.fn(),
13
+ abortRun: vi.fn(),
14
+ } as unknown as HttpAgent;
15
+ const { result } = renderHook(() => useAgUiRuntime({ agent }));
16
+
17
+ expect(Object.hasOwn(result.current, "unstable_getPendingInterrupts")).toBe(
18
+ true,
19
+ );
20
+ expect(
21
+ Object.hasOwn(result.current, "unstable_submitInterruptResponses"),
22
+ ).toBe(true);
23
+ expect(result.current.unstable_getPendingInterrupts()).toEqual([]);
24
+ expect(result.current.unstable_submitInterruptResponses).toEqual(
25
+ expect.any(Function),
26
+ );
27
+ });
28
+
29
+ it("re-renders when the core publishes a state change", () => {
30
+ const agent = {
31
+ runAgent: vi.fn(),
32
+ abortRun: vi.fn(),
33
+ } as unknown as HttpAgent;
34
+ let renders = 0;
35
+ const { result } = renderHook(() => {
36
+ renders++;
37
+ return useAgUiRuntime({ agent });
38
+ });
39
+ const initialRenders = renders;
40
+
41
+ act(() => {
42
+ result.current.thread.append({
43
+ role: "user",
44
+ content: [{ type: "text", text: "hello" }],
45
+ startRun: false,
46
+ });
47
+ });
48
+
49
+ expect(renders).toBeGreaterThan(initialRenders);
50
+ expect(result.current.thread.getState().messages).toHaveLength(1);
51
+ });