@assistant-ui/react-a2a 0.2.40 → 0.2.42

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.
@@ -3,7 +3,7 @@ import { A2AClient } from "./A2AClient.js";
3
3
  import { A2AThreadRuntimeCore } from "./A2AThreadRuntimeCore.js";
4
4
  import { a2aExtras } from "./a2aExtras.js";
5
5
  import { useExternalStoreRuntime, useExternalStoreSharedOptions, useRuntimeAdapters } from "@assistant-ui/core/react";
6
- import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState } from "react";
6
+ import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
7
7
  import { useReplaySafeEffect } from "@assistant-ui/store/internal";
8
8
  //#region src/useA2ARuntime.ts
9
9
  const serializeManagedClientOptions = (options) => {
@@ -14,8 +14,6 @@ const serializeManagedClientOptions = (options) => {
14
14
  });
15
15
  };
16
16
  function useA2ARuntime(options) {
17
- const [_version, setVersion] = useState(0);
18
- const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);
19
17
  const runtimeAdapters = useRuntimeAdapters();
20
18
  const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;
21
19
  const threadListAdapter = options.adapters?.threadList;
@@ -70,8 +68,7 @@ function useA2ARuntime(options) {
70
68
  coreOptionsRef.current = coreOptions;
71
69
  const createCore = () => new A2AThreadRuntimeCore({
72
70
  ...coreOptionsRef.current,
73
- client,
74
- notifyUpdate
71
+ client
75
72
  });
76
73
  const [pinnedCore, setPinnedCore] = useState(() => ({
77
74
  client,
@@ -86,33 +83,48 @@ function useA2ARuntime(options) {
86
83
  setPinnedCore(currentCore);
87
84
  }
88
85
  const core = currentCore.core;
86
+ const snapshot = useSyncExternalStore(core.subscribe, core.getSnapshot, core.getSnapshot);
89
87
  useEffect(() => {
90
88
  core.updateOptions(coreOptions);
91
89
  });
92
90
  const threadSwitchGenerationRef = useRef(0);
91
+ const switchingGenerationRef = useRef(null);
93
92
  const threadList = useMemo(() => {
94
93
  if (!threadListAdapter) return void 0;
95
94
  const { onSwitchToNewThread, onSwitchToThread } = threadListAdapter;
95
+ const releaseSwitch = (generation) => {
96
+ if (switchingGenerationRef.current === generation) switchingGenerationRef.current = null;
97
+ };
96
98
  return {
97
99
  threadId: threadListAdapter.threadId,
98
100
  onSwitchToNewThread: onSwitchToNewThread ? async () => {
99
101
  const generation = ++threadSwitchGenerationRef.current;
100
- core.applyExternalMessages([]);
101
- core.resetContext();
102
- await onSwitchToNewThread();
103
- if (generation !== threadSwitchGenerationRef.current) return;
104
- core.applyExternalMessages([]);
105
- core.resetContext();
102
+ switchingGenerationRef.current = generation;
103
+ try {
104
+ core.applyExternalMessages([]);
105
+ core.resetContext();
106
+ await onSwitchToNewThread();
107
+ if (generation !== threadSwitchGenerationRef.current) return;
108
+ core.applyExternalMessages([]);
109
+ core.resetContext();
110
+ } finally {
111
+ releaseSwitch(generation);
112
+ }
106
113
  } : void 0,
107
114
  onSwitchToThread: onSwitchToThread ? async (threadId) => {
108
115
  const generation = ++threadSwitchGenerationRef.current;
109
- core.applyExternalMessages([]);
110
- core.resetContext();
111
- const result = await onSwitchToThread(threadId);
112
- if (generation !== threadSwitchGenerationRef.current) return;
113
- core.applyExternalMessages([]);
114
- core.applyExternalMessages(result.messages);
115
- core.resetContext();
116
+ switchingGenerationRef.current = generation;
117
+ try {
118
+ core.applyExternalMessages([]);
119
+ core.resetContext();
120
+ const result = await onSwitchToThread(threadId);
121
+ if (generation !== threadSwitchGenerationRef.current) return;
122
+ core.applyExternalMessages([]);
123
+ core.applyExternalMessages(result.messages);
124
+ core.resetContext();
125
+ } finally {
126
+ releaseSwitch(generation);
127
+ }
116
128
  } : void 0
117
129
  };
118
130
  }, [threadListAdapter, core]);
@@ -133,19 +145,19 @@ function useA2ARuntime(options) {
133
145
  const store = useMemo(() => {
134
146
  return {
135
147
  ...shared,
136
- isLoading: core.isLoading,
137
- messageRepository: core.getMessageRepository(),
138
- isRunning: core.isRunning(),
148
+ isLoading: snapshot.isLoading,
149
+ messageRepository: snapshot.messageRepository,
150
+ isRunning: snapshot.isRunning,
139
151
  unstable_persistsHistory: true,
140
152
  extras: a2aExtras.provide({
141
- task: core.getTask(),
142
- artifacts: core.getArtifacts(),
143
- agentCard: core.getAgentCard()
153
+ task: snapshot.task,
154
+ artifacts: snapshot.artifacts,
155
+ agentCard: snapshot.agentCard
144
156
  }),
145
- onNew: (message) => core.append(message),
157
+ onNew: (message) => switchingGenerationRef.current === null ? core.append(message) : Promise.resolve(),
146
158
  onVoiceTranscript: (message) => core.appendVoiceTranscript(message),
147
- onEdit: (message) => core.edit(message),
148
- onReload: (parentId) => core.reload(parentId),
159
+ onEdit: (message) => switchingGenerationRef.current === null ? core.edit(message) : Promise.resolve(),
160
+ onReload: (parentId) => switchingGenerationRef.current === null ? core.reload(parentId) : Promise.resolve(),
149
161
  onCancel: () => core.cancel(),
150
162
  unstable_onRecordToolInteraction: (options) => core.recordToolInteraction(options),
151
163
  setMessages: (messages) => core.applyExternalMessages(messages),
@@ -155,7 +167,7 @@ function useA2ARuntime(options) {
155
167
  }, [
156
168
  adapterAdapters,
157
169
  core,
158
- _version,
170
+ snapshot,
159
171
  shared
160
172
  ]);
161
173
  const runtime = useExternalStoreRuntime(store);
@@ -1 +1 @@
1
- {"version":3,"file":"useA2ARuntime.js","names":[],"sources":["../src/useA2ARuntime.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useInsertionEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport {\n useExternalStoreRuntime,\n useExternalStoreSharedOptions,\n useRuntimeAdapters,\n} from \"@assistant-ui/core/react\";\nimport { useReplaySafeEffect } from \"@assistant-ui/store/internal\";\nimport type {\n AssistantRuntime,\n AppendMessage,\n ExternalStoreAdapter,\n ThreadMessage,\n} from \"@assistant-ui/core\";\nimport { A2AClient, type A2AClientOptions } from \"./A2AClient\";\nimport { A2AThreadRuntimeCore } from \"./A2AThreadRuntimeCore\";\nimport { a2aExtras } from \"./a2aExtras\";\nimport type { UseA2ARuntimeOptions } from \"./types\";\n\ntype ManagedA2AClientOptions = Omit<A2AClientOptions, \"headers\">;\n\nconst serializeManagedClientOptions = (\n options: ManagedA2AClientOptions,\n): string => {\n const fetchOptions = Object.fromEntries(\n Object.entries(options.fetchOptions ?? {}).sort(([a], [b]) =>\n a.localeCompare(b),\n ),\n );\n\n return JSON.stringify({\n ...options,\n fetchOptions,\n });\n};\n\nexport function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {\n const [_version, setVersion] = useState(0);\n const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);\n const runtimeAdapters = useRuntimeAdapters();\n const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;\n const threadListAdapter = options.adapters?.threadList;\n\n const headersRef = useRef(options.headers);\n useInsertionEffect(() => {\n headersRef.current = options.headers;\n });\n const resolveHeaders = useCallback(() => {\n const headers = headersRef.current;\n return typeof headers === \"function\" ? headers() : (headers ?? {});\n }, []);\n\n const managedClientOptionsKey = options.client\n ? null\n : options.baseUrl\n ? serializeManagedClientOptions({\n baseUrl: options.baseUrl,\n basePath: options.basePath,\n tenant: options.tenant,\n extensions: options.extensions,\n fetchOptions: options.fetchOptions,\n })\n : null;\n\n const createClient = () => {\n if (options.client) return options.client;\n if (!managedClientOptionsKey) {\n throw new Error(\"useA2ARuntime requires either `client` or `baseUrl`\");\n }\n\n return new A2AClient({\n ...(JSON.parse(managedClientOptionsKey) as ManagedA2AClientOptions),\n headers: resolveHeaders,\n });\n };\n const [pinnedClient, setPinnedClient] = useState(() => ({\n key: managedClientOptionsKey,\n provided: options.client,\n client: createClient(),\n }));\n let currentClient = pinnedClient;\n if (\n pinnedClient.key !== managedClientOptionsKey ||\n pinnedClient.provided !== options.client\n ) {\n currentClient = {\n key: managedClientOptionsKey,\n provided: options.client,\n client: createClient(),\n };\n setPinnedClient(currentClient);\n }\n const client = currentClient.client;\n\n const coreOptions = {\n client,\n contextId: options.contextId,\n configuration: options.configuration,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(options.onArtifactComplete && {\n onArtifactComplete: options.onArtifactComplete,\n }),\n ...(historyAdapter && { history: historyAdapter }),\n };\n const coreOptionsRef = useRef(coreOptions);\n coreOptionsRef.current = coreOptions;\n\n const createCore = () =>\n new A2AThreadRuntimeCore({\n ...coreOptionsRef.current,\n client,\n notifyUpdate,\n });\n const [pinnedCore, setPinnedCore] = useState(() => ({\n client,\n core: createCore(),\n }));\n let currentCore = pinnedCore;\n if (pinnedCore.client !== client) {\n currentCore = { client, core: createCore() };\n setPinnedCore(currentCore);\n }\n const core = currentCore.core;\n\n useEffect(() => {\n core.updateOptions(coreOptions);\n });\n\n // Thread list\n const threadSwitchGenerationRef = useRef(0);\n const threadList = useMemo(() => {\n if (!threadListAdapter) return undefined;\n\n const { onSwitchToNewThread, onSwitchToThread } = threadListAdapter;\n\n return {\n threadId: threadListAdapter.threadId,\n onSwitchToNewThread: onSwitchToNewThread\n ? async () => {\n const generation = ++threadSwitchGenerationRef.current;\n // Clear before the thread id flips, or the old messages leak\n // into the new thread as a sibling branch.\n core.applyExternalMessages([]);\n core.resetContext();\n await onSwitchToNewThread();\n if (generation !== threadSwitchGenerationRef.current) return;\n // Apply first so the abort inside resetContext finds an already\n // cleared repository and cannot persist the old thread's partial\n // assistant message.\n core.applyExternalMessages([]);\n core.resetContext();\n }\n : undefined,\n onSwitchToThread: onSwitchToThread\n ? async (threadId: string) => {\n const generation = ++threadSwitchGenerationRef.current;\n // Clear before the thread id flips, or the old messages leak\n // into the new thread as a sibling branch.\n core.applyExternalMessages([]);\n core.resetContext();\n const result = await onSwitchToThread(threadId);\n if (generation !== threadSwitchGenerationRef.current) return;\n core.applyExternalMessages([]);\n core.applyExternalMessages(result.messages);\n core.resetContext();\n }\n : undefined,\n };\n }, [threadListAdapter, core]);\n\n // Adapters\n const adapters = options.adapters;\n const adapterAdapters = useMemo(\n () => ({\n attachments: adapters?.attachments ?? runtimeAdapters?.attachments,\n speech: adapters?.speech,\n dictation: adapters?.dictation,\n voice: adapters?.voice,\n feedback: adapters?.feedback,\n threadList,\n }),\n [adapters, runtimeAdapters, threadList],\n );\n\n // Build store adapter\n const shared = useExternalStoreSharedOptions(options);\n const store = useMemo(() => {\n void _version;\n\n return {\n ...shared,\n isLoading: core.isLoading,\n messageRepository: core.getMessageRepository(),\n isRunning: core.isRunning(),\n unstable_persistsHistory: true,\n extras: a2aExtras.provide({\n task: core.getTask(),\n artifacts: core.getArtifacts(),\n agentCard: core.getAgentCard(),\n }),\n onNew: (message: AppendMessage) => core.append(message),\n onVoiceTranscript: (message: ThreadMessage) =>\n core.appendVoiceTranscript(message),\n onEdit: (message: AppendMessage) => core.edit(message),\n onReload: (parentId: string | null) => core.reload(parentId),\n onCancel: () => core.cancel(),\n unstable_onRecordToolInteraction: (options) =>\n core.recordToolInteraction(options),\n setMessages: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onImport: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n adapters: adapterAdapters,\n } satisfies ExternalStoreAdapter<ThreadMessage>;\n }, [adapterAdapters, core, _version, shared]);\n\n const runtime = useExternalStoreRuntime(store);\n\n useReplaySafeEffect(() => {\n core.attachRuntime(runtime);\n return () => {\n core.detachRuntime();\n };\n }, [core, runtime]);\n\n useEffect(() => {\n core.__internal_load();\n }, [core]);\n\n return runtime;\n}\n"],"mappings":";;;;;;;;AA6BA,MAAM,iCACJ,YACW;CACX,MAAM,eAAe,OAAO,YAC1B,OAAO,QAAQ,QAAQ,gBAAgB,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,OACrD,EAAE,cAAc,CAAC,CACnB,CACF;CAEA,OAAO,KAAK,UAAU;EACpB,GAAG;EACH;CACF,CAAC;AACH;AAEA,SAAgB,cAAc,SAAiD;CAC7E,MAAM,CAAC,UAAU,cAAc,SAAS,CAAC;CACzC,MAAM,eAAe,kBAAkB,YAAY,MAAM,IAAI,CAAC,GAAG,CAAC,CAAC;CACnE,MAAM,kBAAkB,mBAAmB;CAC3C,MAAM,iBAAiB,QAAQ,UAAU,WAAW,iBAAiB;CACrE,MAAM,oBAAoB,QAAQ,UAAU;CAE5C,MAAM,aAAa,OAAO,QAAQ,OAAO;CACzC,yBAAyB;EACvB,WAAW,UAAU,QAAQ;CAC/B,CAAC;CACD,MAAM,iBAAiB,kBAAkB;EACvC,MAAM,UAAU,WAAW;EAC3B,OAAO,OAAO,YAAY,aAAa,QAAQ,IAAK,WAAW,CAAC;CAClE,GAAG,CAAC,CAAC;CAEL,MAAM,0BAA0B,QAAQ,SACpC,OACA,QAAQ,UACN,8BAA8B;EAC5B,SAAS,QAAQ;EACjB,UAAU,QAAQ;EAClB,QAAQ,QAAQ;EAChB,YAAY,QAAQ;EACpB,cAAc,QAAQ;CACxB,CAAC,IACD;CAEN,MAAM,qBAAqB;EACzB,IAAI,QAAQ,QAAQ,OAAO,QAAQ;EACnC,IAAI,CAAC,yBACH,MAAM,IAAI,MAAM,qDAAqD;EAGvE,OAAO,IAAI,UAAU;GACnB,GAAI,KAAK,MAAM,uBAAuB;GACtC,SAAS;EACX,CAAC;CACH;CACA,MAAM,CAAC,cAAc,mBAAmB,gBAAgB;EACtD,KAAK;EACL,UAAU,QAAQ;EAClB,QAAQ,aAAa;CACvB,EAAE;CACF,IAAI,gBAAgB;CACpB,IACE,aAAa,QAAQ,2BACrB,aAAa,aAAa,QAAQ,QAClC;EACA,gBAAgB;GACd,KAAK;GACL,UAAU,QAAQ;GAClB,QAAQ,aAAa;EACvB;EACA,gBAAgB,aAAa;CAC/B;CACA,MAAM,SAAS,cAAc;CAE7B,MAAM,cAAc;EAClB;EACA,WAAW,QAAQ;EACnB,eAAe,QAAQ;EACvB,GAAI,QAAQ,WAAW,EAAE,SAAS,QAAQ,QAAQ;EAClD,GAAI,QAAQ,YAAY,EAAE,UAAU,QAAQ,SAAS;EACrD,GAAI,QAAQ,sBAAsB,EAChC,oBAAoB,QAAQ,mBAC9B;EACA,GAAI,kBAAkB,EAAE,SAAS,eAAe;CAClD;CACA,MAAM,iBAAiB,OAAO,WAAW;CACzC,eAAe,UAAU;CAEzB,MAAM,mBACJ,IAAI,qBAAqB;EACvB,GAAG,eAAe;EAClB;EACA;CACF,CAAC;CACH,MAAM,CAAC,YAAY,iBAAiB,gBAAgB;EAClD;EACA,MAAM,WAAW;CACnB,EAAE;CACF,IAAI,cAAc;CAClB,IAAI,WAAW,WAAW,QAAQ;EAChC,cAAc;GAAE;GAAQ,MAAM,WAAW;EAAE;EAC3C,cAAc,WAAW;CAC3B;CACA,MAAM,OAAO,YAAY;CAEzB,gBAAgB;EACd,KAAK,cAAc,WAAW;CAChC,CAAC;CAGD,MAAM,4BAA4B,OAAO,CAAC;CAC1C,MAAM,aAAa,cAAc;EAC/B,IAAI,CAAC,mBAAmB,OAAO,KAAA;EAE/B,MAAM,EAAE,qBAAqB,qBAAqB;EAElD,OAAO;GACL,UAAU,kBAAkB;GAC5B,qBAAqB,sBACjB,YAAY;IACV,MAAM,aAAa,EAAE,0BAA0B;IAG/C,KAAK,sBAAsB,CAAC,CAAC;IAC7B,KAAK,aAAa;IAClB,MAAM,oBAAoB;IAC1B,IAAI,eAAe,0BAA0B,SAAS;IAItD,KAAK,sBAAsB,CAAC,CAAC;IAC7B,KAAK,aAAa;GACpB,IACA,KAAA;GACJ,kBAAkB,mBACd,OAAO,aAAqB;IAC1B,MAAM,aAAa,EAAE,0BAA0B;IAG/C,KAAK,sBAAsB,CAAC,CAAC;IAC7B,KAAK,aAAa;IAClB,MAAM,SAAS,MAAM,iBAAiB,QAAQ;IAC9C,IAAI,eAAe,0BAA0B,SAAS;IACtD,KAAK,sBAAsB,CAAC,CAAC;IAC7B,KAAK,sBAAsB,OAAO,QAAQ;IAC1C,KAAK,aAAa;GACpB,IACA,KAAA;EACN;CACF,GAAG,CAAC,mBAAmB,IAAI,CAAC;CAG5B,MAAM,WAAW,QAAQ;CACzB,MAAM,kBAAkB,eACf;EACL,aAAa,UAAU,eAAe,iBAAiB;EACvD,QAAQ,UAAU;EAClB,WAAW,UAAU;EACrB,OAAO,UAAU;EACjB,UAAU,UAAU;EACpB;CACF,IACA;EAAC;EAAU;EAAiB;CAAU,CACxC;CAGA,MAAM,SAAS,8BAA8B,OAAO;CACpD,MAAM,QAAQ,cAAc;EAG1B,OAAO;GACL,GAAG;GACH,WAAW,KAAK;GAChB,mBAAmB,KAAK,qBAAqB;GAC7C,WAAW,KAAK,UAAU;GAC1B,0BAA0B;GAC1B,QAAQ,UAAU,QAAQ;IACxB,MAAM,KAAK,QAAQ;IACnB,WAAW,KAAK,aAAa;IAC7B,WAAW,KAAK,aAAa;GAC/B,CAAC;GACD,QAAQ,YAA2B,KAAK,OAAO,OAAO;GACtD,oBAAoB,YAClB,KAAK,sBAAsB,OAAO;GACpC,SAAS,YAA2B,KAAK,KAAK,OAAO;GACrD,WAAW,aAA4B,KAAK,OAAO,QAAQ;GAC3D,gBAAgB,KAAK,OAAO;GAC5B,mCAAmC,YACjC,KAAK,sBAAsB,OAAO;GACpC,cAAc,aACZ,KAAK,sBAAsB,QAAQ;GACrC,WAAW,aACT,KAAK,sBAAsB,QAAQ;GACrC,UAAU;EACZ;CACF,GAAG;EAAC;EAAiB;EAAM;EAAU;CAAM,CAAC;CAE5C,MAAM,UAAU,wBAAwB,KAAK;CAE7C,0BAA0B;EACxB,KAAK,cAAc,OAAO;EAC1B,aAAa;GACX,KAAK,cAAc;EACrB;CACF,GAAG,CAAC,MAAM,OAAO,CAAC;CAElB,gBAAgB;EACd,KAAK,gBAAgB;CACvB,GAAG,CAAC,IAAI,CAAC;CAET,OAAO;AACT"}
1
+ {"version":3,"file":"useA2ARuntime.js","names":[],"sources":["../src/useA2ARuntime.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useInsertionEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\nimport {\n useExternalStoreRuntime,\n useExternalStoreSharedOptions,\n useRuntimeAdapters,\n} from \"@assistant-ui/core/react\";\nimport { useReplaySafeEffect } from \"@assistant-ui/store/internal\";\nimport type {\n AssistantRuntime,\n AppendMessage,\n ExternalStoreAdapter,\n ThreadMessage,\n} from \"@assistant-ui/core\";\nimport { A2AClient, type A2AClientOptions } from \"./A2AClient\";\nimport { A2AThreadRuntimeCore } from \"./A2AThreadRuntimeCore\";\nimport { a2aExtras } from \"./a2aExtras\";\nimport type { UseA2ARuntimeOptions } from \"./types\";\n\ntype ManagedA2AClientOptions = Omit<A2AClientOptions, \"headers\">;\n\nconst serializeManagedClientOptions = (\n options: ManagedA2AClientOptions,\n): string => {\n const fetchOptions = Object.fromEntries(\n Object.entries(options.fetchOptions ?? {}).sort(([a], [b]) =>\n a.localeCompare(b),\n ),\n );\n\n return JSON.stringify({\n ...options,\n fetchOptions,\n });\n};\n\nexport function useA2ARuntime(options: UseA2ARuntimeOptions): AssistantRuntime {\n const runtimeAdapters = useRuntimeAdapters();\n const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;\n const threadListAdapter = options.adapters?.threadList;\n\n const headersRef = useRef(options.headers);\n useInsertionEffect(() => {\n headersRef.current = options.headers;\n });\n const resolveHeaders = useCallback(() => {\n const headers = headersRef.current;\n return typeof headers === \"function\" ? headers() : (headers ?? {});\n }, []);\n\n const managedClientOptionsKey = options.client\n ? null\n : options.baseUrl\n ? serializeManagedClientOptions({\n baseUrl: options.baseUrl,\n basePath: options.basePath,\n tenant: options.tenant,\n extensions: options.extensions,\n fetchOptions: options.fetchOptions,\n })\n : null;\n\n const createClient = () => {\n if (options.client) return options.client;\n if (!managedClientOptionsKey) {\n throw new Error(\"useA2ARuntime requires either `client` or `baseUrl`\");\n }\n\n return new A2AClient({\n ...(JSON.parse(managedClientOptionsKey) as ManagedA2AClientOptions),\n headers: resolveHeaders,\n });\n };\n const [pinnedClient, setPinnedClient] = useState(() => ({\n key: managedClientOptionsKey,\n provided: options.client,\n client: createClient(),\n }));\n let currentClient = pinnedClient;\n if (\n pinnedClient.key !== managedClientOptionsKey ||\n pinnedClient.provided !== options.client\n ) {\n currentClient = {\n key: managedClientOptionsKey,\n provided: options.client,\n client: createClient(),\n };\n setPinnedClient(currentClient);\n }\n const client = currentClient.client;\n\n const coreOptions = {\n client,\n contextId: options.contextId,\n configuration: options.configuration,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(options.onArtifactComplete && {\n onArtifactComplete: options.onArtifactComplete,\n }),\n ...(historyAdapter && { history: historyAdapter }),\n };\n const coreOptionsRef = useRef(coreOptions);\n coreOptionsRef.current = coreOptions;\n\n const createCore = () =>\n new A2AThreadRuntimeCore({\n ...coreOptionsRef.current,\n client,\n });\n const [pinnedCore, setPinnedCore] = useState(() => ({\n client,\n core: createCore(),\n }));\n let currentCore = pinnedCore;\n if (pinnedCore.client !== client) {\n currentCore = { client, core: createCore() };\n setPinnedCore(currentCore);\n }\n const core = currentCore.core;\n const snapshot = useSyncExternalStore(\n core.subscribe,\n core.getSnapshot,\n core.getSnapshot,\n );\n\n useEffect(() => {\n core.updateOptions(coreOptions);\n });\n\n // Thread list\n const threadSwitchGenerationRef = useRef(0);\n const switchingGenerationRef = useRef<number | null>(null);\n const threadList = useMemo(() => {\n if (!threadListAdapter) return undefined;\n\n const { onSwitchToNewThread, onSwitchToThread } = threadListAdapter;\n\n const releaseSwitch = (generation: number) => {\n if (switchingGenerationRef.current === generation) {\n switchingGenerationRef.current = null;\n }\n };\n\n return {\n threadId: threadListAdapter.threadId,\n onSwitchToNewThread: onSwitchToNewThread\n ? async () => {\n const generation = ++threadSwitchGenerationRef.current;\n switchingGenerationRef.current = generation;\n try {\n // Clear before the thread id flips, or the old messages leak\n // into the new thread as a sibling branch.\n core.applyExternalMessages([]);\n core.resetContext();\n await onSwitchToNewThread();\n if (generation !== threadSwitchGenerationRef.current) return;\n // Apply first so the abort inside resetContext finds an already\n // cleared repository and cannot persist the old thread's partial\n // assistant message.\n core.applyExternalMessages([]);\n core.resetContext();\n } finally {\n releaseSwitch(generation);\n }\n }\n : undefined,\n onSwitchToThread: onSwitchToThread\n ? async (threadId: string) => {\n const generation = ++threadSwitchGenerationRef.current;\n switchingGenerationRef.current = generation;\n try {\n // Clear before the thread id flips, or the old messages leak\n // into the new thread as a sibling branch.\n core.applyExternalMessages([]);\n core.resetContext();\n const result = await onSwitchToThread(threadId);\n if (generation !== threadSwitchGenerationRef.current) return;\n core.applyExternalMessages([]);\n core.applyExternalMessages(result.messages);\n core.resetContext();\n } finally {\n releaseSwitch(generation);\n }\n }\n : undefined,\n };\n }, [threadListAdapter, core]);\n\n // Adapters\n const adapters = options.adapters;\n const adapterAdapters = useMemo(\n () => ({\n attachments: adapters?.attachments ?? runtimeAdapters?.attachments,\n speech: adapters?.speech,\n dictation: adapters?.dictation,\n voice: adapters?.voice,\n feedback: adapters?.feedback,\n threadList,\n }),\n [adapters, runtimeAdapters, threadList],\n );\n\n // Build store adapter\n const shared = useExternalStoreSharedOptions(options);\n const store = useMemo(() => {\n return {\n ...shared,\n isLoading: snapshot.isLoading,\n messageRepository: snapshot.messageRepository,\n isRunning: snapshot.isRunning,\n unstable_persistsHistory: true,\n extras: a2aExtras.provide({\n task: snapshot.task,\n artifacts: snapshot.artifacts,\n agentCard: snapshot.agentCard,\n }),\n onNew: (message: AppendMessage) =>\n switchingGenerationRef.current === null\n ? core.append(message)\n : Promise.resolve(),\n onVoiceTranscript: (message: ThreadMessage) =>\n core.appendVoiceTranscript(message),\n onEdit: (message: AppendMessage) =>\n switchingGenerationRef.current === null\n ? core.edit(message)\n : Promise.resolve(),\n onReload: (parentId: string | null) =>\n switchingGenerationRef.current === null\n ? core.reload(parentId)\n : Promise.resolve(),\n onCancel: () => core.cancel(),\n unstable_onRecordToolInteraction: (options) =>\n core.recordToolInteraction(options),\n setMessages: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onImport: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n adapters: adapterAdapters,\n } satisfies ExternalStoreAdapter<ThreadMessage>;\n }, [adapterAdapters, core, snapshot, shared]);\n\n const runtime = useExternalStoreRuntime(store);\n\n useReplaySafeEffect(() => {\n core.attachRuntime(runtime);\n return () => {\n core.detachRuntime();\n };\n }, [core, runtime]);\n\n useEffect(() => {\n core.__internal_load();\n }, [core]);\n\n return runtime;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-a2a",
3
- "version": "0.2.40",
3
+ "version": "0.2.42",
4
4
  "description": "A2A (Agent-to-Agent) v1.0 protocol adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "a2a",
@@ -29,10 +29,10 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@assistant-ui/core": "^0.3.23",
33
- "@assistant-ui/react-generative-ui": "^0.0.23",
34
- "@assistant-ui/store": "^0.3.17",
35
- "assistant-stream": "^0.3.47"
32
+ "@assistant-ui/core": "^0.3.25",
33
+ "@assistant-ui/generative-ui": "^0.0.1",
34
+ "@assistant-ui/store": "^0.3.18",
35
+ "assistant-stream": "^0.3.49"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@types/react": "*",
@@ -49,7 +49,7 @@
49
49
  "@testing-library/dom": "^10.4.2",
50
50
  "@testing-library/react": "^16.3.3",
51
51
  "@types/react": "^19.3.0",
52
- "jsdom": "^30.0.1",
52
+ "jsdom": "^30.1.2",
53
53
  "react": "^19.3.0",
54
54
  "react-dom": "^19.3.0",
55
55
  "vitest": "^5.0.3"
@@ -70,6 +70,7 @@
70
70
  "scripts": {
71
71
  "build": "aui-build",
72
72
  "test": "vitest run",
73
+ "test:coverage": "vitest run --coverage --coverage.reporter=text --coverage.reporter=html --coverage.reporter=json-summary",
73
74
  "typecheck": "tsc --noEmit",
74
75
  "test:peer-react18": "vitest run --config vitest.peer-react18.config.ts",
75
76
  "test:types:peer-react18": "aui-react18-types",
@@ -1149,6 +1149,75 @@ describe("A2AClient", () => {
1149
1149
  expect(evt.event.status.message?.role).toBe("agent");
1150
1150
  });
1151
1151
 
1152
+ it("forwards configurable SSE line and event limits", async () => {
1153
+ const sseData = JSON.stringify({
1154
+ status_update: {
1155
+ task_id: "t1",
1156
+ context_id: "ctx-1",
1157
+ status: {
1158
+ state: "TASK_STATE_WORKING",
1159
+ message: {
1160
+ message_id: "s1",
1161
+ role: "ROLE_AGENT",
1162
+ parts: [{ text: "x".repeat(64) }],
1163
+ },
1164
+ },
1165
+ },
1166
+ });
1167
+ const consume = async (options: {
1168
+ maxStreamLineLength: number;
1169
+ maxStreamEventLength: number;
1170
+ }) => {
1171
+ fetchMock.mockResolvedValueOnce(
1172
+ mockSSEResponse([`data: ${sseData}`, "", ""]),
1173
+ );
1174
+ const limitedClient = new A2AClient({
1175
+ baseUrl: "https://agent.test",
1176
+ ...options,
1177
+ });
1178
+ const events: A2AStreamEvent[] = [];
1179
+ for await (const event of limitedClient.streamMessage(userMessage)) {
1180
+ events.push(event);
1181
+ }
1182
+ return events;
1183
+ };
1184
+
1185
+ await expect(
1186
+ consume({ maxStreamLineLength: 32, maxStreamEventLength: 1_024 }),
1187
+ ).rejects.toThrow("SSE line exceeds maxLineLength");
1188
+ await expect(
1189
+ consume({ maxStreamLineLength: 1_024, maxStreamEventLength: 32 }),
1190
+ ).rejects.toThrow("SSE event exceeds maxEventLength");
1191
+ await expect(
1192
+ consume({ maxStreamLineLength: 1_024, maxStreamEventLength: 1_024 }),
1193
+ ).resolves.toHaveLength(1);
1194
+ });
1195
+
1196
+ it("releases the response reader when decoder limits are invalid", async () => {
1197
+ const cancel = vi.fn();
1198
+ const body = new ReadableStream<Uint8Array>({ cancel });
1199
+ fetchMock.mockResolvedValueOnce(
1200
+ new Response(body, {
1201
+ headers: { "Content-Type": "text/event-stream" },
1202
+ }),
1203
+ );
1204
+ const limitedClient = new A2AClient({
1205
+ baseUrl: "https://agent.test",
1206
+ maxStreamLineLength: 0,
1207
+ });
1208
+ const consume = async () => {
1209
+ for await (const _event of limitedClient.streamMessage(userMessage)) {
1210
+ void _event;
1211
+ }
1212
+ };
1213
+
1214
+ await expect(consume()).rejects.toThrow(
1215
+ "maxLineLength must be a positive safe integer",
1216
+ );
1217
+ expect(cancel).toHaveBeenCalledOnce();
1218
+ expect(body.locked).toBe(false);
1219
+ });
1220
+
1152
1221
  it("drops a wrapped task or message whose ids are not strings", async () => {
1153
1222
  const frames = [
1154
1223
  {
package/src/A2AClient.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SSEEventDecoder, type SSEEvent } from "assistant-stream/utils";
2
- import { isRecord } from "@assistant-ui/core/internal";
2
+ import { isRecord, raceWithAbortSignal } from "@assistant-ui/core/internal";
3
3
  import type {
4
4
  A2AAgentCard,
5
5
  A2AErrorInfo,
@@ -33,6 +33,10 @@ export type A2AClientOptions = {
33
33
  fetchOptions?:
34
34
  | Omit<RequestInit, "headers" | "body" | "method" | "signal">
35
35
  | undefined;
36
+ /** Maximum UTF-16 code units accepted in one SSE line. Defaults to 16 MiB. */
37
+ maxStreamLineLength?: number | undefined;
38
+ /** Maximum UTF-16 code units retained across one SSE event. Defaults to 16 MiB. */
39
+ maxStreamEventLength?: number | undefined;
36
40
  };
37
41
 
38
42
  export class A2AError extends Error {
@@ -680,49 +684,6 @@ function signalInit(signal?: AbortSignal): RequestInit {
680
684
  return signal ? { signal } : {};
681
685
  }
682
686
 
683
- const getAbortReason = (signal: AbortSignal): unknown => {
684
- if (signal.reason !== undefined) return signal.reason;
685
- const error = new Error("The operation was aborted");
686
- error.name = "AbortError";
687
- return error;
688
- };
689
-
690
- const raceWithAbortSignal = <T>(
691
- signal: AbortSignal | undefined,
692
- operation: () => T | PromiseLike<T>,
693
- ): Promise<T> => {
694
- if (!signal) return Promise.resolve().then(operation);
695
- if (signal.aborted) return Promise.reject(getAbortReason(signal));
696
-
697
- return new Promise<T>((resolve, reject) => {
698
- let settled = false;
699
- const cleanup = () => signal.removeEventListener("abort", handleAbort);
700
- const resolveOnce = (value: T) => {
701
- if (settled) return;
702
- settled = true;
703
- cleanup();
704
- resolve(value);
705
- };
706
- const rejectOnce = (error: unknown) => {
707
- if (settled) return;
708
- settled = true;
709
- cleanup();
710
- reject(error);
711
- };
712
- const handleAbort = () => rejectOnce(getAbortReason(signal));
713
-
714
- signal.addEventListener("abort", handleAbort, { once: true });
715
- let result: T | PromiseLike<T>;
716
- try {
717
- result = operation();
718
- } catch (error) {
719
- rejectOnce(error);
720
- return;
721
- }
722
- Promise.resolve(result).then(resolveOnce, rejectOnce);
723
- });
724
- };
725
-
726
687
  const SKIPPED_FRAME_SNIPPET_LENGTH = 120;
727
688
 
728
689
  function describeSkippedFrame(data: string, reason: string): string {
@@ -739,6 +700,8 @@ export class A2AClient {
739
700
  private basePath: string;
740
701
  private tenant: string | undefined;
741
702
  private extensionUris: string[] | undefined;
703
+ private maxStreamLineLength: number | undefined;
704
+ private maxStreamEventLength: number | undefined;
742
705
  private fetchOptions: Omit<
743
706
  RequestInit,
744
707
  "headers" | "body" | "method" | "signal"
@@ -754,6 +717,8 @@ export class A2AClient {
754
717
  : "";
755
718
  this.tenant = options.tenant;
756
719
  this.extensionUris = options.extensions;
720
+ this.maxStreamLineLength = options.maxStreamLineLength;
721
+ this.maxStreamEventLength = options.maxStreamEventLength;
757
722
  const {
758
723
  headers: _h,
759
724
  body: _b,
@@ -1112,7 +1077,6 @@ export class A2AClient {
1112
1077
  if (!reader) throw new Error("No response body");
1113
1078
 
1114
1079
  const decoder = new TextDecoder();
1115
- const sseDecoder = new SSEEventDecoder();
1116
1080
 
1117
1081
  let firstSkipReason: string | undefined;
1118
1082
  const noteSkip = (data: string, reason: string) => {
@@ -1148,6 +1112,10 @@ export class A2AClient {
1148
1112
 
1149
1113
  let shouldCancel = true;
1150
1114
  try {
1115
+ const sseDecoder = new SSEEventDecoder({
1116
+ maxLineLength: this.maxStreamLineLength,
1117
+ maxEventLength: this.maxStreamEventLength,
1118
+ });
1151
1119
  while (true) {
1152
1120
  let result: ReadableStreamReadResult<Uint8Array>;
1153
1121
  try {
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import {
3
3
  applyA2uiOperations,
4
4
  convertSurfaceToUISpec,
5
- } from "@assistant-ui/react-generative-ui/a2ui";
5
+ } from "@assistant-ui/generative-ui/a2ui";
6
6
  import { A2AThreadRuntimeCore } from "./A2AThreadRuntimeCore";
7
7
  import type { A2AClient } from "./A2AClient";
8
8
  import type {
@@ -155,10 +155,10 @@ const toolInteraction = {
155
155
  };
156
156
 
157
157
  describe("A2AThreadRuntimeCore", () => {
158
- let notifyUpdate: ReturnType<typeof vi.fn>;
158
+ let onUpdate = vi.fn<() => void>();
159
159
 
160
160
  beforeEach(() => {
161
- notifyUpdate = vi.fn();
161
+ onUpdate = vi.fn<() => void>();
162
162
  });
163
163
 
164
164
  afterEach(() => {
@@ -169,11 +169,12 @@ describe("A2AThreadRuntimeCore", () => {
169
169
  clientOverrides: Partial<A2AClient> = {},
170
170
  coreOverrides: Record<string, unknown> = {},
171
171
  ) {
172
- return new A2AThreadRuntimeCore({
172
+ const core = new A2AThreadRuntimeCore({
173
173
  client: createMockClient(clientOverrides),
174
- notifyUpdate: notifyUpdate as unknown as () => void,
175
174
  ...coreOverrides,
176
175
  });
176
+ core.subscribe(onUpdate);
177
+ return core;
177
178
  }
178
179
 
179
180
  describe("late history loading", () => {
@@ -406,11 +407,11 @@ describe("A2AThreadRuntimeCore", () => {
406
407
  expect(core.isLoading).toBe(true);
407
408
  core.applyExternalMessages([]);
408
409
  core.resetContext();
409
- const updatesAfterReplacement = notifyUpdate.mock.calls.length;
410
+ const updatesAfterReplacement = onUpdate.mock.calls.length;
410
411
  expect(core.isLoading).toBe(false);
411
412
  resolve({ messages: [] });
412
413
  await loading;
413
- expect(notifyUpdate).toHaveBeenCalledTimes(updatesAfterReplacement);
414
+ expect(onUpdate).toHaveBeenCalledTimes(updatesAfterReplacement);
414
415
  });
415
416
 
416
417
  it("does not import old history when agent-card discovery settles after a thread switch", async () => {
@@ -635,13 +636,10 @@ describe("A2AThreadRuntimeCore", () => {
635
636
  const client = createMockClient({ streamMessage });
636
637
  const core = new A2AThreadRuntimeCore({
637
638
  client,
638
- notifyUpdate: notifyUpdate as unknown as () => void,
639
639
  });
640
640
 
641
641
  await core.append(createUserAppendMessage("First"));
642
642
 
643
- // useA2ARuntime re-applies its options on every render, including the
644
- // renders triggered by the stream's own notifyUpdate calls.
645
643
  core.updateOptions({ client, contextId: undefined });
646
644
 
647
645
  await core.append(createUserAppendMessage("Second"));
@@ -657,7 +655,6 @@ describe("A2AThreadRuntimeCore", () => {
657
655
  const client = createMockClient({ streamMessage });
658
656
  const core = new A2AThreadRuntimeCore({
659
657
  client,
660
- notifyUpdate: notifyUpdate as unknown as () => void,
661
658
  });
662
659
 
663
660
  await core.append(createUserAppendMessage("First"));
@@ -711,7 +708,6 @@ describe("A2AThreadRuntimeCore", () => {
711
708
  const client = createMockClient({ streamMessage });
712
709
  const core = new A2AThreadRuntimeCore({
713
710
  client,
714
- notifyUpdate: notifyUpdate as unknown as () => void,
715
711
  });
716
712
 
717
713
  await core.append(createUserAppendMessage("First"));
@@ -731,7 +727,6 @@ describe("A2AThreadRuntimeCore", () => {
731
727
  const client = createMockClient({ streamMessage });
732
728
  const core = new A2AThreadRuntimeCore({
733
729
  client,
734
- notifyUpdate: notifyUpdate as unknown as () => void,
735
730
  });
736
731
 
737
732
  await core.append(createUserAppendMessage("First"));
@@ -1778,7 +1773,6 @@ describe("A2AThreadRuntimeCore", () => {
1778
1773
  }),
1779
1774
  }),
1780
1775
  onArtifactComplete,
1781
- notifyUpdate: notifyUpdate as unknown as () => void,
1782
1776
  });
1783
1777
 
1784
1778
  await core.append(createUserAppendMessage("Go"));
@@ -2363,6 +2357,65 @@ describe("A2AThreadRuntimeCore", () => {
2363
2357
  });
2364
2358
  });
2365
2359
 
2360
+ // --- Runtime detachment ---
2361
+
2362
+ describe("runtime detachment", () => {
2363
+ it("keeps an onCancel replacement run abortable", async () => {
2364
+ const streamSignals: AbortSignal[] = [];
2365
+ const releaseStreams: Array<() => void> = [];
2366
+ const streamMessage = vi.fn().mockImplementation(async function* (
2367
+ _msg: any,
2368
+ _cfg: any,
2369
+ _meta: any,
2370
+ signal: AbortSignal,
2371
+ ) {
2372
+ const streamIndex = streamSignals.push(signal) - 1;
2373
+ yield statusUpdateEvent("working");
2374
+ if (signal.aborted) return;
2375
+ await new Promise<void>((resolve) => {
2376
+ releaseStreams[streamIndex] = resolve;
2377
+ signal.addEventListener("abort", () => resolve(), { once: true });
2378
+ });
2379
+ });
2380
+ let core!: A2AThreadRuntimeCore;
2381
+ let replacementRun: Promise<void> | undefined;
2382
+ let startReplacement = true;
2383
+ core = createCore(
2384
+ {
2385
+ getAgentCard: vi.fn().mockResolvedValue({
2386
+ name: "Agent",
2387
+ url: "https://agent.example",
2388
+ }),
2389
+ streamMessage,
2390
+ },
2391
+ {
2392
+ onCancel: () => {
2393
+ if (!startReplacement) return;
2394
+ startReplacement = false;
2395
+ replacementRun = core.append(
2396
+ createUserAppendMessage("Replacement"),
2397
+ );
2398
+ },
2399
+ },
2400
+ );
2401
+
2402
+ const firstRun = core.append(createUserAppendMessage("First"));
2403
+ await vi.waitFor(() => expect(streamSignals).toHaveLength(1));
2404
+
2405
+ core.detachRuntime();
2406
+ await vi.waitFor(() => expect(streamSignals).toHaveLength(2));
2407
+ await vi.waitFor(() => expect(releaseStreams[1]).toBeTypeOf("function"));
2408
+
2409
+ await core.cancel();
2410
+ const replacementWasAborted = streamSignals[1]!.aborted;
2411
+ releaseStreams[1]!();
2412
+ await Promise.all([firstRun, replacementRun]);
2413
+
2414
+ expect(streamSignals[0]!.aborted).toBe(true);
2415
+ expect(replacementWasAborted).toBe(true);
2416
+ });
2417
+ });
2418
+
2366
2419
  // --- Cancel ---
2367
2420
 
2368
2421
  describe("cancel", () => {
@@ -2380,13 +2433,13 @@ describe("A2AThreadRuntimeCore", () => {
2380
2433
  status: { state: "working" },
2381
2434
  };
2382
2435
  (core as any).abortController = new AbortController();
2383
- notifyUpdate.mockClear();
2436
+ onUpdate.mockClear();
2384
2437
 
2385
2438
  await core.cancel();
2386
2439
 
2387
2440
  expect(cancelTask).toHaveBeenCalledWith("t1");
2388
2441
  expect(core.getTask()!.status.state).toBe("canceled");
2389
- expect(notifyUpdate).toHaveBeenCalledOnce();
2442
+ expect(onUpdate).toHaveBeenCalledOnce();
2390
2443
  });
2391
2444
 
2392
2445
  it("does nothing when no abort controller", async () => {
@@ -2487,6 +2540,67 @@ describe("A2AThreadRuntimeCore", () => {
2487
2540
  expect(core.getTask()?.status.state).toBe("working");
2488
2541
  });
2489
2542
 
2543
+ it("keeps the run onCancel starts when a send supersedes the active run", async () => {
2544
+ const signals: AbortSignal[] = [];
2545
+ const streamMessage = vi.fn().mockImplementation(async function* (
2546
+ _msg: any,
2547
+ _cfg: any,
2548
+ _meta: any,
2549
+ signal: AbortSignal,
2550
+ ) {
2551
+ signals.push(signal);
2552
+ await new Promise<void>((resolve) => {
2553
+ if (signal.aborted) resolve();
2554
+ else
2555
+ signal.addEventListener("abort", () => resolve(), { once: true });
2556
+ });
2557
+ });
2558
+ let restarted = false;
2559
+ let core!: A2AThreadRuntimeCore;
2560
+ core = createCore(
2561
+ { streamMessage },
2562
+ {
2563
+ onCancel: () => {
2564
+ if (restarted) return;
2565
+ restarted = true;
2566
+ void core.append({
2567
+ ...createUserAppendMessage("from onCancel"),
2568
+ parentId: core.getMessages().at(-1)!.id,
2569
+ });
2570
+ },
2571
+ },
2572
+ );
2573
+
2574
+ void core.append(createUserAppendMessage("first"));
2575
+ await vi.waitFor(() => expect(signals).toHaveLength(1));
2576
+ void core.append({
2577
+ ...createUserAppendMessage("second"),
2578
+ parentId: core.getMessages().at(-1)!.id,
2579
+ });
2580
+ await vi.waitFor(() => expect(signals).toHaveLength(2));
2581
+ await new Promise((resolve) => setTimeout(resolve, 0));
2582
+ expect(signals).toHaveLength(2);
2583
+ expect(
2584
+ core.getMessages().map((message) => ({
2585
+ role: message.role,
2586
+ text: message.content.map((part) =>
2587
+ part.type === "text" ? part.text : "",
2588
+ ),
2589
+ })),
2590
+ ).toEqual([
2591
+ { role: "user", text: ["first"] },
2592
+ { role: "assistant", text: [] },
2593
+ { role: "user", text: ["second"] },
2594
+ { role: "user", text: ["from onCancel"] },
2595
+ { role: "assistant", text: [] },
2596
+ ]);
2597
+
2598
+ await core.cancel();
2599
+
2600
+ expect(signals.every((signal) => signal.aborted)).toBe(true);
2601
+ expect(core.isRunning()).toBe(false);
2602
+ });
2603
+
2490
2604
  it("still cancels the server task when onCancel clears the thread", async () => {
2491
2605
  let resolveCancel!: (task: A2ATask) => void;
2492
2606
  const cancelTask = vi.fn().mockReturnValue(
@@ -2602,7 +2716,6 @@ describe("A2AThreadRuntimeCore", () => {
2602
2716
  })),
2603
2717
  }),
2604
2718
  onError,
2605
- notifyUpdate: notifyUpdate as unknown as () => void,
2606
2719
  });
2607
2720
 
2608
2721
  await expect(core.append(createUserAppendMessage("Go"))).rejects.toThrow(
@@ -2848,7 +2961,6 @@ describe("outbound message conversion", () => {
2848
2961
  });
2849
2962
  const core = new A2AThreadRuntimeCore({
2850
2963
  client: createMockClient({ streamMessage }),
2851
- notifyUpdate: vi.fn() as unknown as () => void,
2852
2964
  });
2853
2965
  return { core, streamMessage };
2854
2966
  }