@assistant-ui/react-ag-ui 0.0.58 → 0.0.60

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 (45) hide show
  1. package/dist/agUiExtras.d.ts +1 -2
  2. package/dist/agUiExtras.d.ts.map +1 -1
  3. package/dist/hooks.d.ts +6 -7
  4. package/dist/hooks.d.ts.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +17 -6
  7. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  8. package/dist/runtime/AgUiThreadRuntimeCore.js +95 -47
  9. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  10. package/dist/runtime/adapter/conversions.d.ts +11 -11
  11. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  12. package/dist/runtime/adapter/conversions.js +114 -17
  13. package/dist/runtime/adapter/conversions.js.map +1 -1
  14. package/dist/runtime/adapter/run-aggregator.d.ts +9 -10
  15. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  16. package/dist/runtime/adapter/run-aggregator.js +59 -0
  17. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  18. package/dist/runtime/adapter/subscriber.d.ts +2 -3
  19. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  20. package/dist/runtime/adapter/tool-approval.d.ts +4 -5
  21. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  22. package/dist/runtime/event-parser.d.ts +2 -3
  23. package/dist/runtime/event-parser.d.ts.map +1 -1
  24. package/dist/runtime/interrupt-internals.d.ts +3 -4
  25. package/dist/runtime/interrupt-internals.d.ts.map +1 -1
  26. package/dist/runtime/logger.d.ts +2 -3
  27. package/dist/runtime/logger.d.ts.map +1 -1
  28. package/dist/runtime/mcp-tool-result.d.ts +3 -4
  29. package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
  30. package/dist/runtime/types.d.ts +35 -11
  31. package/dist/runtime/types.d.ts.map +1 -1
  32. package/dist/useAgUiRuntime.d.ts +2 -3
  33. package/dist/useAgUiRuntime.d.ts.map +1 -1
  34. package/dist/useAgUiRuntime.js +14 -5
  35. package/dist/useAgUiRuntime.js.map +1 -1
  36. package/package.json +12 -11
  37. package/src/index.ts +1 -0
  38. package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
  39. package/src/runtime/AgUiThreadRuntimeCore.ts +176 -63
  40. package/src/runtime/adapter/conversions.ts +200 -26
  41. package/src/runtime/adapter/run-aggregator.ts +85 -0
  42. package/src/runtime/types.ts +26 -0
  43. package/src/useAgUiRuntime.queue.test.tsx +54 -0
  44. package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
  45. package/src/useAgUiRuntime.ts +14 -3
@@ -5,6 +5,7 @@ import { generateId } from "@assistant-ui/core";
5
5
  import type {
6
6
  ThreadMessageLike as CoreThreadMessageLike,
7
7
  PartProviderMetadata,
8
+ ReasoningMessagePart,
8
9
  ThreadMessage,
9
10
  ToolCallMessagePartMcpMetadata,
10
11
  ToolModelContentPart,
@@ -44,14 +45,14 @@ type AttachmentLike = {
44
45
  };
45
46
 
46
47
  type ThreadMessageLike = {
47
- id?: string;
48
+ id?: string | undefined;
48
49
  role: string;
49
50
  content: unknown;
50
51
  metadata?: unknown;
51
- name?: string;
52
- toolCallId?: string;
53
- error?: string;
54
- attachments?: readonly AttachmentLike[];
52
+ name?: string | undefined;
53
+ toolCallId?: string | undefined;
54
+ error?: string | undefined;
55
+ attachments?: readonly AttachmentLike[] | undefined;
55
56
  };
56
57
 
57
58
  type NormalizedThreadMessageLike = ThreadMessageLike & { id: string };
@@ -733,6 +734,112 @@ function attachA2uiSurfaces(
733
734
  return { ...message, content: [...preserved, ...a2uiParts] };
734
735
  }
735
736
 
737
+ // A tool call that arrives without a `parentMessageId` has no record to join, so
738
+ // the client opens one keyed by the call id. An assistant record whose own id is
739
+ // a call it carries is that container: an id the client invented for the call
740
+ // rather than a boundary an agent drew. The prose the run answers with lands in
741
+ // a record of its own, and nothing on the wire binds the two to the run that
742
+ // produced them.
743
+ function isSyntheticToolCallContainer(message: CoreThreadMessageLike): boolean {
744
+ if (message.role !== "assistant" || !Array.isArray(message.content))
745
+ return false;
746
+ let carriesOwnId = false;
747
+ for (const part of message.content) {
748
+ if (!isObject(part)) continue;
749
+ // The container is opened empty, so text on the record means an agent
750
+ // addressed it and the id is one it chose.
751
+ if (part.type === "text") return false;
752
+ if (
753
+ part.type === "tool-call" &&
754
+ getString(part, "toolCallId") === message.id
755
+ ) {
756
+ carriesOwnId = true;
757
+ }
758
+ }
759
+ return carriesOwnId;
760
+ }
761
+
762
+ function carriesText(message: CoreThreadMessageLike): boolean {
763
+ return (
764
+ Array.isArray(message.content) &&
765
+ message.content.some((part) => isObject(part) && part.type === "text")
766
+ );
767
+ }
768
+
769
+ // What the run put after the container folds onto it: another container for a
770
+ // parallel call, or the parts of the answer. A record carrying a tool call an
771
+ // agent addressed is a boundary of its own and keeps its message.
772
+ function foldsOntoToolCallContainer(message: CoreThreadMessageLike): boolean {
773
+ if (message.role !== "assistant" || !Array.isArray(message.content))
774
+ return false;
775
+ if (message.content.length === 0) return false;
776
+ return (
777
+ isSyntheticToolCallContainer(message) ||
778
+ !message.content.some((part) => isObject(part) && part.type === "tool-call")
779
+ );
780
+ }
781
+
782
+ function readCustomMetadata(metadata: unknown): Record<string, unknown> {
783
+ if (!isObject(metadata) || !isObject(metadata.custom)) return {};
784
+ return metadata.custom;
785
+ }
786
+
787
+ function readNamespacedMetadata(metadata: unknown): Record<string, unknown> {
788
+ const namespaced = readCustomMetadata(metadata)[AG_UI_METADATA_NAMESPACE];
789
+ return isObject(namespaced) ? namespaced : {};
790
+ }
791
+
792
+ function foldOntoToolCallContainer(
793
+ container: CoreThreadMessageLike,
794
+ message: CoreThreadMessageLike,
795
+ ): CoreThreadMessageLike {
796
+ const interrupts = [
797
+ ...(readPersistedInterrupts(container.metadata) ?? []),
798
+ ...(readPersistedInterrupts(message.metadata) ?? []),
799
+ ];
800
+ // An entry that rode the folded record sat ahead of content that now follows
801
+ // the container's calls, so it is replayed after the merged record and its
802
+ // tool results rather than ahead of a message that no longer starts there.
803
+ const opaqueReasoning = [
804
+ ...readOpaqueReasoning(container.metadata),
805
+ ...readOpaqueReasoning(message.metadata).map((entry) => ({
806
+ ...entry,
807
+ after: true,
808
+ })),
809
+ ];
810
+ const namespaced = {
811
+ ...readNamespacedMetadata(container.metadata),
812
+ ...readNamespacedMetadata(message.metadata),
813
+ ...(interrupts.length > 0 ? { interrupts } : {}),
814
+ ...(opaqueReasoning.length > 0 ? { opaqueReasoning } : {}),
815
+ } satisfies AgUiCustomMetadata & Record<string, unknown>;
816
+ const custom = {
817
+ ...readCustomMetadata(container.metadata),
818
+ ...readCustomMetadata(message.metadata),
819
+ ...(Object.keys(namespaced).length > 0
820
+ ? { [AG_UI_METADATA_NAMESPACE]: namespaced }
821
+ : {}),
822
+ };
823
+ const metadata = {
824
+ ...(isObject(container.metadata) ? container.metadata : {}),
825
+ ...(isObject(message.metadata) ? message.metadata : {}),
826
+ ...(Object.keys(custom).length > 0 ? { custom } : {}),
827
+ };
828
+ return {
829
+ ...container,
830
+ ...message,
831
+ // The container's id names a call rather than the turn, so the record the
832
+ // agent addressed its prose to wins: that is the id the next run has to
833
+ // carry for the agent to recognize the message it wrote.
834
+ id: carriesText(message) ? message.id : container.id,
835
+ content: [
836
+ ...(Array.isArray(container.content) ? container.content : []),
837
+ ...(Array.isArray(message.content) ? message.content : []),
838
+ ],
839
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
840
+ } as CoreThreadMessageLike;
841
+ }
842
+
736
843
  export type FromAgUiMessagesOptions = {
737
844
  /**
738
845
  * Whether to convert `reasoning` messages into visible reasoning parts.
@@ -754,6 +861,40 @@ export function fromAgUiMessages(
754
861
  // neighbouring message instead of being dropped. The anchor is the index the
755
862
  // next pushed message will occupy, which is where it sat on the wire.
756
863
  const opaqueReasoning: (AgUiOpaqueReasoning & { anchor: number })[] = [];
864
+ // A reasoning record belongs to the assistant record that follows it, which is
865
+ // the binding the export writes and the one the reference integrations read
866
+ // back. Holding it until that assistant arrives rebuilds the pair as the
867
+ // single message the live run produced.
868
+ const pendingReasoning: {
869
+ id: string | undefined;
870
+ part: ReasoningMessagePart;
871
+ }[] = [];
872
+
873
+ const flushPendingReasoning = () => {
874
+ for (const { id, part } of pendingReasoning) {
875
+ converted.push({
876
+ id: id ?? generateId(),
877
+ role: "assistant",
878
+ content: [part],
879
+ });
880
+ }
881
+ pendingReasoning.length = 0;
882
+ };
883
+
884
+ const withPendingReasoning = (
885
+ message: CoreThreadMessageLike,
886
+ ): CoreThreadMessageLike => {
887
+ if (pendingReasoning.length === 0) return message;
888
+ const parts = pendingReasoning.map(({ part }) => part);
889
+ pendingReasoning.length = 0;
890
+ return {
891
+ ...message,
892
+ content: [
893
+ ...parts,
894
+ ...(Array.isArray(message.content) ? message.content : []),
895
+ ],
896
+ };
897
+ };
757
898
 
758
899
  for (const rawMessage of messages) {
759
900
  if (!isObject(rawMessage)) continue;
@@ -761,6 +902,7 @@ export function fromAgUiMessages(
761
902
  if (!role) continue;
762
903
 
763
904
  if (role === "tool") {
905
+ flushPendingReasoning();
764
906
  const toolCallId = getToolCallId(rawMessage) ?? `tool-${generateId()}`;
765
907
  const toolMessageId = getString(rawMessage, "id");
766
908
  const modelContent = extractText(rawMessage.content);
@@ -872,6 +1014,11 @@ export function fromAgUiMessages(
872
1014
  const operations = activityContent?.["a2ui_operations"];
873
1015
  if (!Array.isArray(operations)) continue;
874
1016
 
1017
+ // A surface belongs to the turn that painted it, and held reasoning is a
1018
+ // nearer antecedent than the previous turn's assistant record, so it is
1019
+ // released here rather than folded past this record.
1020
+ flushPendingReasoning();
1021
+
875
1022
  let ownerIndex = -1;
876
1023
  for (let i = converted.length - 1; i >= 0; i--) {
877
1024
  const candidate = converted[i];
@@ -903,7 +1050,9 @@ export function fromAgUiMessages(
903
1050
  }
904
1051
 
905
1052
  if (role === "assistant") {
906
- converted.push(toAssistantSnapshotMessage(rawMessage));
1053
+ converted.push(
1054
+ withPendingReasoning(toAssistantSnapshotMessage(rawMessage)),
1055
+ );
907
1056
  continue;
908
1057
  }
909
1058
 
@@ -916,6 +1065,9 @@ export function fromAgUiMessages(
916
1065
  // accept the next run.
917
1066
  const opaqueId = getString(rawMessage, "id");
918
1067
  if (opaqueId?.trim() && encryptedValue?.trim()) {
1068
+ // The anchor counts materialized messages, so anything still held
1069
+ // takes its own slot rather than folding past this record.
1070
+ flushPendingReasoning();
919
1071
  opaqueReasoning.push({
920
1072
  id: opaqueId,
921
1073
  encryptedValue,
@@ -939,31 +1091,37 @@ export function fromAgUiMessages(
939
1091
  }
940
1092
  continue;
941
1093
  }
942
- converted.push({
943
- id: getString(rawMessage, "id") ?? generateId(),
944
- role: "assistant",
945
- content: [
946
- {
947
- type: "reasoning",
948
- text,
949
- ...(encryptedValue !== undefined
950
- ? {
951
- providerMetadata: {
952
- [AG_UI_METADATA_NAMESPACE]: { encryptedValue },
953
- },
954
- }
955
- : {}),
956
- },
957
- ],
1094
+ const rawReasoningId = getString(rawMessage, "id");
1095
+ // A blank id still wins over a synthesized one on export, which would put
1096
+ // an unaddressable record on the wire.
1097
+ const reasoningId = rawReasoningId?.trim() ? rawReasoningId : undefined;
1098
+ // The fold costs the record its own message id, so it rides the part
1099
+ // instead: the export re-emits each block under the id it arrived with.
1100
+ const meta = {
1101
+ ...(reasoningId !== undefined ? { reasoningId } : {}),
1102
+ ...(encryptedValue !== undefined ? { encryptedValue } : {}),
1103
+ };
1104
+ pendingReasoning.push({
1105
+ id: reasoningId,
1106
+ part: {
1107
+ type: "reasoning",
1108
+ text,
1109
+ ...(Object.keys(meta).length > 0
1110
+ ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
1111
+ : {}),
1112
+ },
958
1113
  });
959
1114
  continue;
960
1115
  }
961
1116
 
962
1117
  if (role === "user" || role === "system" || role === "developer") {
1118
+ flushPendingReasoning();
963
1119
  converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
964
1120
  }
965
1121
  }
966
1122
 
1123
+ flushPendingReasoning();
1124
+
967
1125
  for (const { anchor, ...entry } of opaqueReasoning) {
968
1126
  // Nothing followed it on the wire, so it trails the last message instead.
969
1127
  const trailing = anchor >= converted.length;
@@ -976,8 +1134,24 @@ export function fromAgUiMessages(
976
1134
  ]);
977
1135
  }
978
1136
 
979
- for (let i = 0; i < converted.length; i++) {
980
- const message = converted[i]!;
1137
+ // A turn that called a tool before answering sits on the wire as a container
1138
+ // and the records that follow it, which the live run renders as one message.
1139
+ const folded: CoreThreadMessageLike[] = [];
1140
+ for (const message of converted) {
1141
+ const previous = folded[folded.length - 1];
1142
+ if (
1143
+ previous !== undefined &&
1144
+ isSyntheticToolCallContainer(previous) &&
1145
+ foldsOntoToolCallContainer(message)
1146
+ ) {
1147
+ folded[folded.length - 1] = foldOntoToolCallContainer(previous, message);
1148
+ continue;
1149
+ }
1150
+ folded.push(message);
1151
+ }
1152
+
1153
+ for (let i = 0; i < folded.length; i++) {
1154
+ const message = folded[i]!;
981
1155
  if (message.role !== "assistant") continue;
982
1156
 
983
1157
  const hasInterrupt =
@@ -992,7 +1166,7 @@ export function fromAgUiMessages(
992
1166
  );
993
1167
 
994
1168
  if (hasInterrupt || hasPendingToolCall) {
995
- converted[i] = {
1169
+ folded[i] = {
996
1170
  ...message,
997
1171
  status: getAutoStatus(
998
1172
  false,
@@ -1005,7 +1179,7 @@ export function fromAgUiMessages(
1005
1179
  }
1006
1180
  }
1007
1181
 
1008
- return converted;
1182
+ return folded;
1009
1183
  }
1010
1184
 
1011
1185
  function convertAssistantMessage(
@@ -87,6 +87,7 @@ type ToolCallState = {
87
87
  toolCallId: string;
88
88
  toolCallName: string;
89
89
  argsText: string;
90
+ argsTextScanner?: JSONContainerScanner | undefined;
90
91
  parsedArgs: Record<string, unknown> | undefined;
91
92
  result: unknown;
92
93
  isError: boolean | undefined;
@@ -99,6 +100,80 @@ type ToolCallState = {
99
100
  subagentRunId?: string;
100
101
  };
101
102
 
103
+ type JSONContainerScanner = {
104
+ state: "pending" | "open" | "complete" | "invalid";
105
+ stack: ("{" | "[")[];
106
+ inString: boolean;
107
+ escaped: boolean;
108
+ };
109
+
110
+ const createJSONContainerScanner = (): JSONContainerScanner => ({
111
+ state: "pending",
112
+ stack: [],
113
+ inString: false,
114
+ escaped: false,
115
+ });
116
+
117
+ const isJSONWhitespace = (char: string): boolean =>
118
+ char === " " || char === "\t" || char === "\n" || char === "\r";
119
+
120
+ const scanJSONContainerDelta = (
121
+ scanner: JSONContainerScanner,
122
+ delta: string,
123
+ ): boolean => {
124
+ let completed = false;
125
+
126
+ for (const char of delta) {
127
+ if (scanner.state === "invalid") continue;
128
+ if (scanner.state === "complete") {
129
+ if (isJSONWhitespace(char)) continue;
130
+ scanner.state = "invalid";
131
+ continue;
132
+ }
133
+ if (scanner.state === "pending") {
134
+ if (isJSONWhitespace(char)) continue;
135
+ if (char !== "{" && char !== "[") {
136
+ scanner.state = "invalid";
137
+ continue;
138
+ }
139
+ scanner.state = "open";
140
+ scanner.stack.push(char);
141
+ continue;
142
+ }
143
+ if (scanner.inString) {
144
+ if (scanner.escaped) {
145
+ scanner.escaped = false;
146
+ } else if (char === "\\") {
147
+ scanner.escaped = true;
148
+ } else if (char === '"') {
149
+ scanner.inString = false;
150
+ }
151
+ continue;
152
+ }
153
+ if (char === '"') {
154
+ scanner.inString = true;
155
+ continue;
156
+ }
157
+ if (char === "{" || char === "[") {
158
+ scanner.stack.push(char);
159
+ continue;
160
+ }
161
+ if (char === "}" || char === "]") {
162
+ const expected = char === "}" ? "{" : "[";
163
+ if (scanner.stack.pop() !== expected) {
164
+ scanner.state = "invalid";
165
+ continue;
166
+ }
167
+ if (scanner.stack.length === 0) {
168
+ scanner.state = "complete";
169
+ completed = true;
170
+ }
171
+ }
172
+ }
173
+
174
+ return completed && scanner.state === "complete";
175
+ };
176
+
102
177
  export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
103
178
 
104
179
  export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
@@ -816,7 +891,17 @@ export class RunAggregator {
816
891
  private appendToolArgs(id: string | undefined, delta: string) {
817
892
  const entry = id ? this.toolCalls.get(id) : undefined;
818
893
  if (!entry) return;
894
+ if (!entry.argsTextScanner) {
895
+ entry.argsTextScanner = createJSONContainerScanner();
896
+ }
819
897
  entry.argsText += delta;
898
+ const shouldParse = scanJSONContainerDelta(entry.argsTextScanner, delta);
899
+ if (!shouldParse) {
900
+ if (entry.argsTextScanner.state === "invalid") {
901
+ entry.parsedArgs = undefined;
902
+ }
903
+ return;
904
+ }
820
905
  try {
821
906
  const parsed = JSON.parse(entry.argsText);
822
907
  if (parsed && typeof parsed === "object") {
@@ -56,10 +56,36 @@ export type UseAgUiRuntimeAdapters = {
56
56
  threadList?: UseAgUiThreadListAdapter;
57
57
  };
58
58
 
59
+ export type AgUiResumeTranscript = "full" | "appended";
60
+
59
61
  export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
60
62
  agent: AbstractAgent;
61
63
  logger?: Partial<Logger>;
62
64
  showThinking?: boolean;
65
+ /**
66
+ * What `messages` carries on a resume run, meaning a `RunAgentInput` that
67
+ * also carries `resume`. The AG-UI interrupt spec leaves this undefined: its
68
+ * contract rules constrain the thread id, interrupt coverage, idempotency,
69
+ * and expiry, and none of them mentions `messages`. Hosts therefore disagree,
70
+ * and both readings are conformant.
71
+ *
72
+ * `"full"` sends the whole thread, which is what `@ag-ui/client` itself does.
73
+ * Hosts that resume from a checkpoint ignore the transcript, and hosts that
74
+ * rebuild the interrupted run from it need it.
75
+ *
76
+ * `"appended"` sends only what was appended locally after the interrupted
77
+ * assistant message, which is nothing for an approval or a denial and the new
78
+ * user turn for `steerAway`. Choose it for a host that owns the thread and
79
+ * seeds a resume request from its own stored snapshot, because such a host
80
+ * appends the request body to that snapshot and a re-sent transcript
81
+ * duplicates the interrupted turn in its stored history. A host that rebuilds
82
+ * the run from `messages` has nothing to resume from under `"appended"`, and
83
+ * a host that derives its outgoing `MESSAGES_SNAPSHOT` from the request body
84
+ * emits a truncated one.
85
+ *
86
+ * Defaults to `"full"`.
87
+ */
88
+ resumeTranscript?: AgUiResumeTranscript | undefined;
63
89
  /**
64
90
  * When the user sends, edits, or reloads a message while client-side tool
65
91
  * calls are still pending, automatically cancel the unresolved tool calls
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { describe, it, expect, vi, afterEach } from "vitest";
4
+ import { startTransition, useEffect } from "react";
4
5
  import {
5
6
  act,
6
7
  cleanup,
@@ -409,6 +410,59 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
409
410
  );
410
411
  });
411
412
 
413
+ it("does not clear the committed queue from a suspended render", async () => {
414
+ const { agent, runAgent } = gatedAgent();
415
+ let runtime: AssistantRuntime | undefined;
416
+ const never = new Promise<void>(() => {});
417
+
418
+ const Harness = ({
419
+ enabled,
420
+ suspend,
421
+ }: {
422
+ enabled: boolean;
423
+ suspend: boolean;
424
+ }) => {
425
+ const nextRuntime = useAgUiRuntime({
426
+ agent,
427
+ unstable_enableMessageQueue: enabled,
428
+ });
429
+ useEffect(() => {
430
+ runtime = nextRuntime;
431
+ }, [nextRuntime]);
432
+ if (suspend) throw never;
433
+ return null;
434
+ };
435
+
436
+ const view = render(<Harness enabled suspend={false} />);
437
+ await waitFor(() => expect(runtime).toBeDefined());
438
+ mount(runtime!);
439
+
440
+ await act(async () => {
441
+ await runtime!.thread.append({
442
+ role: "user",
443
+ content: [{ type: "text", text: "first" }],
444
+ });
445
+ });
446
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
447
+
448
+ await act(async () => {
449
+ await runtime!.thread.append({
450
+ role: "user",
451
+ content: [{ type: "text", text: "second" }],
452
+ parentId: runtime!.thread.getState().messages.at(-1)?.id ?? null,
453
+ });
454
+ });
455
+ await waitFor(() =>
456
+ expect(screen.getByTestId("queued").textContent).toBe("second"),
457
+ );
458
+
459
+ act(() => {
460
+ startTransition(() => view.rerender(<Harness enabled={false} suspend />));
461
+ });
462
+
463
+ expect(screen.getByTestId("queued").textContent).toBe("second");
464
+ });
465
+
412
466
  it("leaves the queue capability off when the flag is not set", () => {
413
467
  const { agent } = gatedAgent();
414
468