@assistant-ui/react-ag-ui 0.0.36 → 0.0.39

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.
@@ -13,7 +13,7 @@ import type {
13
13
  ThreadMessage,
14
14
  ToolCallMessagePart,
15
15
  } from "@assistant-ui/core";
16
- import type { HttpAgent } from "@ag-ui/client";
16
+ import type { AbstractAgent } from "@ag-ui/client";
17
17
  import jsonpatch, { type Operation } from "fast-json-patch";
18
18
  import type { Logger } from "./logger";
19
19
  import type { AgUiEvent, AgUiInterrupt, AgUiResumeEntry } from "./types";
@@ -22,6 +22,7 @@ import {
22
22
  AG_UI_METADATA_NAMESPACE,
23
23
  type AgUiCustomMetadata,
24
24
  RunAggregator,
25
+ tryParseJSON,
25
26
  } from "./adapter/run-aggregator";
26
27
  import {
27
28
  fromAgUiMessages,
@@ -48,7 +49,7 @@ type ResumeRunConfig = {
48
49
  };
49
50
 
50
51
  type CoreOptions = {
51
- agent: HttpAgent;
52
+ agent: AbstractAgent;
52
53
  logger: Logger;
53
54
  showThinking: boolean;
54
55
  onError?: (error: Error) => void;
@@ -60,7 +61,7 @@ type CoreOptions = {
60
61
  const FALLBACK_USER_STATUS = { type: "complete", reason: "unknown" } as const;
61
62
 
62
63
  export class AgUiThreadRuntimeCore {
63
- private agent: HttpAgent;
64
+ private agent: AbstractAgent;
64
65
  private logger: Logger;
65
66
  private showThinking: boolean;
66
67
  private onError: ((error: Error) => void) | undefined;
@@ -214,6 +215,30 @@ export class AgUiThreadRuntimeCore {
214
215
  );
215
216
  }
216
217
 
218
+ async resumeInFlightRun(messages: readonly ThreadMessage[]): Promise<void> {
219
+ // Without a resume stream startRun would re-run the agent from scratch.
220
+ const resumeStream = this.history?.resume?.bind(this.history);
221
+ if (!resumeStream) {
222
+ const error = new Error(
223
+ "[agui] unstable_resume requires a ThreadHistoryAdapter with a resume() method; skipping resume after thread switch",
224
+ );
225
+ this.logger.error?.(error.message);
226
+ this.onError?.(error);
227
+ return;
228
+ }
229
+ const parentId = messages.at(-1)?.id ?? null;
230
+ try {
231
+ await this.startRun(
232
+ parentId,
233
+ this.lastRunConfig,
234
+ undefined,
235
+ resumeStream,
236
+ );
237
+ } catch {
238
+ // startRun already reported via onError; don't reject the switch.
239
+ }
240
+ }
241
+
217
242
  private assertNoPendingInterrupts(): void {
218
243
  if (!this.getPendingInterrupts()) return;
219
244
  throw new Error(
@@ -396,27 +421,37 @@ export class AgUiThreadRuntimeCore {
396
421
  this.maybeResumeAfterToolResults(options.messageId);
397
422
  }
398
423
 
399
- // Re-evaluate whether every tool call on the assistant message now has a
400
- // result and, if so, drive the follow-up run. Centralizing this lets the
401
- // continuation fire whether the frontend result lands before RUN_FINISHED
402
- // (status flips to requires-action only later, while a run is still draining)
403
- // or after it. Idempotent: once the message is marked complete it no-ops.
424
+ // The continuation fires whether the frontend result lands before
425
+ // RUN_FINISHED (the status flips to requires-action only later, while the
426
+ // run is still draining) or after it.
404
427
  private maybeResumeAfterToolResults(messageId: string): void {
428
+ if (!this.maybeCompleteAfterToolResults(messageId)) return;
429
+
430
+ if (this.isRunningFlag) {
431
+ // A run is still draining (RUN_FINISHED arrived but the stream has not
432
+ // closed). Defer until startRun's tail so we never start two runs.
433
+ this.pendingResumeMessageId = messageId;
434
+ return;
435
+ }
436
+ this.startResumeRun(messageId);
437
+ }
438
+
439
+ private maybeCompleteAfterToolResults(messageId: string): boolean {
405
440
  const message = this.messages.find((m) => m.id === messageId);
406
- if (!message || message.role !== "assistant") return;
441
+ if (!message || message.role !== "assistant") return false;
407
442
  const assistant = message as ThreadAssistantMessage;
408
443
  if (
409
444
  assistant.status?.type !== "requires-action" ||
410
445
  assistant.status.reason !== "tool-calls"
411
446
  ) {
412
- return;
447
+ return false;
413
448
  }
414
449
  const allResolved = assistant.content.every(
415
450
  (part) =>
416
451
  part.type !== "tool-call" ||
417
452
  ("result" in part && part.result !== undefined),
418
453
  );
419
- if (!allResolved) return;
454
+ if (!allResolved) return false;
420
455
 
421
456
  this.messages = this.messages.map((m) =>
422
457
  m.id === messageId && m.role === "assistant"
@@ -428,14 +463,7 @@ export class AgUiThreadRuntimeCore {
428
463
  );
429
464
  this.notifyUpdate();
430
465
  this.persistAssistantHistory(messageId);
431
-
432
- if (this.isRunningFlag) {
433
- // A run is still draining (RUN_FINISHED arrived but the stream has not
434
- // closed). Defer until startRun's tail so we never start two runs.
435
- this.pendingResumeMessageId = messageId;
436
- return;
437
- }
438
- this.startResumeRun(messageId);
466
+ return true;
439
467
  }
440
468
 
441
469
  private startResumeRun(messageId: string): void {
@@ -722,6 +750,7 @@ export class AgUiThreadRuntimeCore {
722
750
  unstable_annotations: [],
723
751
  unstable_data: [],
724
752
  steps: [],
753
+ isOptimistic: true,
725
754
  custom: {},
726
755
  },
727
756
  };
@@ -742,9 +771,11 @@ export class AgUiThreadRuntimeCore {
742
771
  );
743
772
  this.messages = this.messages.filter((m) => m.id !== oldId);
744
773
  } else {
745
- this.messages = this.messages.map((m) =>
746
- m.id === oldId ? { ...m, id: newId } : m,
747
- );
774
+ this.messages = this.messages.map((m) => {
775
+ if (m.id !== oldId) return m;
776
+ const { isOptimistic: _, ...metadata } = m.metadata;
777
+ return { ...m, id: newId, metadata } as ThreadMessage;
778
+ });
748
779
  }
749
780
 
750
781
  const pendingParent = this.assistantHistoryParents.get(oldId);
@@ -869,6 +900,7 @@ export class AgUiThreadRuntimeCore {
869
900
  unstable_annotations: annotations,
870
901
  unstable_data: data,
871
902
  steps,
903
+ ...(current.isOptimistic ? { isOptimistic: true } : {}),
872
904
  ...(incoming.timing ? { timing: incoming.timing } : {}),
873
905
  custom: incoming.custom
874
906
  ? { ...current.custom, ...incoming.custom }
@@ -904,11 +936,57 @@ export class AgUiThreadRuntimeCore {
904
936
  this.importMessagesSnapshot(event.messages);
905
937
  return;
906
938
  }
939
+ case "TOOL_CALL_RESULT": {
940
+ if (!aggregator.hasToolCall(event.toolCallId)) {
941
+ const messageId = this.findMessageIdForToolCall(event.toolCallId);
942
+ if (messageId !== undefined) {
943
+ this.applyCrossRunToolResult(messageId, event);
944
+ return;
945
+ }
946
+ }
947
+ aggregator.handle(event);
948
+ return;
949
+ }
907
950
  default:
908
951
  aggregator.handle(event);
909
952
  }
910
953
  }
911
954
 
955
+ private applyCrossRunToolResult(
956
+ messageId: string,
957
+ event: Extract<AgUiEvent, { type: "TOOL_CALL_RESULT" }>,
958
+ ): void {
959
+ let updated = false;
960
+ this.messages = this.messages.map((message) => {
961
+ if (message.id !== messageId || message.role !== "assistant")
962
+ return message;
963
+ const assistant = message as ThreadAssistantMessage;
964
+ let matchedToolCall = false;
965
+ const content = assistant.content.map((part) => {
966
+ if (part.type !== "tool-call" || part.toolCallId !== event.toolCallId)
967
+ return part;
968
+ matchedToolCall = true;
969
+ return {
970
+ ...part,
971
+ result: tryParseJSON(event.content ?? "") as ReadonlyJSONValue,
972
+ ...(event.role === "tool" ? { isError: false } : {}),
973
+ ...(event.messageId
974
+ ? { unstable_toolMessageId: event.messageId }
975
+ : {}),
976
+ };
977
+ });
978
+ if (!matchedToolCall) return message;
979
+ updated = true;
980
+ return { ...assistant, content };
981
+ });
982
+
983
+ if (!updated) return;
984
+ this.notifyUpdate();
985
+ // Not maybeResumeAfterToolResults: the delivering run is already in
986
+ // flight, and a resume from the owner would reset the head past it.
987
+ this.maybeCompleteAfterToolResults(messageId);
988
+ }
989
+
912
990
  private importMessagesSnapshot(rawMessages: readonly unknown[]) {
913
991
  try {
914
992
  const normalized = fromAgUiMessages(rawMessages, {
@@ -204,6 +204,115 @@ function toInputContent(
204
204
  return null;
205
205
  }
206
206
 
207
+ type SnapshotAttachment = NonNullable<
208
+ CoreThreadMessageLike["attachments"]
209
+ >[number];
210
+
211
+ const mediaInputTypes = new Set(["image", "audio", "video", "document"]);
212
+
213
+ // Inverse of buildInputSource.
214
+ function inputSourceToString(
215
+ value: unknown,
216
+ ): { value: string; mimeType?: string } | null {
217
+ if (!isObject(value)) return null;
218
+ const sourceValue = getString(value, "value");
219
+ if (sourceValue === undefined) return null;
220
+ const mimeType = getString(value, "mimeType");
221
+ const type = getString(value, "type");
222
+ if (type === "url") {
223
+ return { value: sourceValue, ...(mimeType !== undefined && { mimeType }) };
224
+ }
225
+ if (type === "data") {
226
+ const resolvedMimeType = mimeType ?? "application/octet-stream";
227
+ return {
228
+ value: `data:${resolvedMimeType};base64,${sourceValue}`,
229
+ mimeType: resolvedMimeType,
230
+ };
231
+ }
232
+ return null;
233
+ }
234
+
235
+ // Mirrors @ag-ui/client's BackwardCompatibility_0_0_47 middleware.
236
+ function upgradeBinaryInputPart(
237
+ part: Record<string, unknown>,
238
+ ): Record<string, unknown> | null {
239
+ const mimeType = getString(part, "mimeType");
240
+ if (mimeType === undefined) return null;
241
+ const data = getString(part, "data");
242
+ const url = getString(part, "url");
243
+ const source = data
244
+ ? { type: "data", value: data, mimeType }
245
+ : url
246
+ ? { type: "url", value: url, mimeType }
247
+ : null;
248
+ if (!source) return null;
249
+ const filename = getString(part, "filename");
250
+ return {
251
+ type: mediaTypeForMime(mimeType),
252
+ source,
253
+ ...(filename && { metadata: { filename } }),
254
+ };
255
+ }
256
+
257
+ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
258
+ if (!Array.isArray(content)) return [];
259
+
260
+ const attachments: SnapshotAttachment[] = [];
261
+ for (const rawPart of content) {
262
+ if (!isObject(rawPart)) continue;
263
+ const part =
264
+ getString(rawPart, "type") === "binary"
265
+ ? upgradeBinaryInputPart(rawPart)
266
+ : rawPart;
267
+ if (!part) continue;
268
+ const type = getString(part, "type");
269
+ if (type === undefined || !mediaInputTypes.has(type)) continue;
270
+ const source = inputSourceToString(part.source);
271
+ if (!source) continue;
272
+
273
+ const filename = isObject(part.metadata)
274
+ ? getString(part.metadata, "filename")
275
+ : undefined;
276
+ const id = attachments.length.toString();
277
+
278
+ if (type === "image") {
279
+ attachments.push({
280
+ id,
281
+ type: "image",
282
+ name: filename ?? "image",
283
+ ...(source.mimeType !== undefined && { contentType: source.mimeType }),
284
+ status: { type: "complete" },
285
+ content: [
286
+ {
287
+ type: "image",
288
+ image: source.value,
289
+ ...(filename !== undefined && { filename }),
290
+ },
291
+ ],
292
+ });
293
+ continue;
294
+ }
295
+
296
+ const mimeType = source.mimeType ?? "application/octet-stream";
297
+ attachments.push({
298
+ id,
299
+ type: type === "document" ? "document" : "file",
300
+ name: filename ?? "file",
301
+ contentType: mimeType,
302
+ status: { type: "complete" },
303
+ content: [
304
+ {
305
+ type: "file",
306
+ data: source.value,
307
+ mimeType,
308
+ ...(filename !== undefined && { filename }),
309
+ },
310
+ ],
311
+ });
312
+ }
313
+ return attachments;
314
+ }
315
+
207
316
  function buildUserContent(message: ThreadMessageLike): string | InputContent[] {
208
317
  // File parts in message.content are intentionally skipped: the canonical
209
318
  // binary payload for files always flows through message.attachments.
@@ -353,11 +462,14 @@ function toUserOrSystemSnapshotMessage(
353
462
  rawMessage: Record<string, unknown>,
354
463
  ): CoreThreadMessageLike {
355
464
  const messageName = getString(rawMessage, "name");
465
+ const attachments =
466
+ role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
356
467
  return {
357
468
  id: getString(rawMessage, "id") ?? generateId(),
358
469
  role,
359
470
  content: extractText(rawMessage.content),
360
471
  ...(messageName !== undefined ? { name: messageName } : {}),
472
+ ...(attachments.length > 0 ? { attachments } : {}),
361
473
  };
362
474
  }
363
475
 
@@ -82,6 +82,10 @@ export class RunAggregator {
82
82
  this.onServerMessageId = options.onServerMessageId;
83
83
  }
84
84
 
85
+ hasToolCall(toolCallId: string): boolean {
86
+ return this.toolCalls.has(toolCallId);
87
+ }
88
+
85
89
  handle(event: AgUiEvent): void {
86
90
  switch (event.type) {
87
91
  case "RUN_STARTED": {
@@ -388,7 +392,7 @@ export class RunAggregator {
388
392
  ) {
389
393
  this.partOrder.push({ kind: "tool-call", toolCallId: id });
390
394
  }
391
- entry.result = this.tryParseJSON(content);
395
+ entry.result = tryParseJSON(content);
392
396
  entry.isError = isError;
393
397
  if (toolMessageId) {
394
398
  entry.toolMessageId = toolMessageId;
@@ -396,15 +400,6 @@ export class RunAggregator {
396
400
  this.lastResolvedToolCallId = id;
397
401
  }
398
402
 
399
- private tryParseJSON(value: string): unknown {
400
- if (!value) return value;
401
- try {
402
- return JSON.parse(value);
403
- } catch {
404
- return value;
405
- }
406
- }
407
-
408
403
  private emit(): void {
409
404
  const snapshot: ThreadAssistantMessagePart[] = [];
410
405
 
@@ -542,3 +537,12 @@ export class RunAggregator {
542
537
  this.emit();
543
538
  }
544
539
  }
540
+
541
+ export function tryParseJSON(value: string): unknown {
542
+ if (!value) return value;
543
+ try {
544
+ return JSON.parse(value);
545
+ } catch {
546
+ return value;
547
+ }
548
+ }
@@ -38,6 +38,16 @@ type Subscriber = {
38
38
  onRunFailed?: (payload: { error: Error }) => void;
39
39
  };
40
40
 
41
+ const isAbortError = (error: Error): boolean => {
42
+ if (error.name === "AbortError") return true;
43
+ const message = typeof error.message === "string" ? error.message : "";
44
+ return (
45
+ message === "Fetch is aborted" ||
46
+ message === "signal is aborted without reason" ||
47
+ message === "component unmounted"
48
+ );
49
+ };
50
+
41
51
  const ensureEvent = (
42
52
  raw: unknown,
43
53
  type: AgUiEvent["type"],
@@ -143,7 +153,12 @@ export const createAgUiSubscriber = (
143
153
  dispatch({ type: "RUN_FINISHED", runId });
144
154
  },
145
155
  onRunFailed: ({ error }) => {
156
+ runFinishedDispatched = true;
146
157
  onRunFailed?.(error);
158
+ if (isAbortError(error)) {
159
+ dispatch({ type: "RUN_CANCELLED" } satisfies AgUiEvent);
160
+ return;
161
+ }
147
162
  const message =
148
163
  typeof error.message === "string" ? error.message : "Run failed";
149
164
  const code =
@@ -2,29 +2,42 @@ import type {
2
2
  AttachmentAdapter,
3
3
  DictationAdapter,
4
4
  ExternalStoreSharedOptions,
5
+ ExternalStoreThreadListAdapter,
5
6
  FeedbackAdapter,
6
7
  RealtimeVoiceAdapter,
7
8
  SpeechSynthesisAdapter,
8
9
  ThreadHistoryAdapter,
9
10
  ThreadMessage,
10
11
  } from "@assistant-ui/core";
11
- import type { HttpAgent } from "@ag-ui/client";
12
+ import type { AbstractAgent } from "@ag-ui/client";
12
13
  import type { Logger } from "./logger";
13
14
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
14
15
 
15
16
  /**
16
17
  * @experimental This API is still under active development and might change without notice.
18
+ *
19
+ * Same as ExternalStoreThreadListAdapter, except `onSwitchToThread` returns
20
+ * the messages (and optional state) to hydrate the thread with.
17
21
  */
18
- export type UseAgUiThreadListAdapter = {
19
- threadId?: string | undefined;
20
- onSwitchToNewThread?: (() => Promise<void> | void) | undefined;
22
+ type SwitchToThreadResult = {
23
+ messages: readonly ThreadMessage[];
24
+ state?: ReadonlyJSONValue;
25
+ /**
26
+ * Set when the thread has a run in flight. The runtime resumes the run
27
+ * after hydrating, the same way `ThreadHistoryAdapter.load()` does when it
28
+ * returns `unstable_resume: true`.
29
+ */
30
+ unstable_resume?: boolean;
31
+ };
32
+
33
+ export type UseAgUiThreadListAdapter = Omit<
34
+ ExternalStoreThreadListAdapter,
35
+ "onSwitchToThread"
36
+ > & {
21
37
  onSwitchToThread?:
22
- | ((threadId: string) =>
23
- | Promise<{
24
- messages: readonly ThreadMessage[];
25
- state?: ReadonlyJSONValue;
26
- }>
27
- | { messages: readonly ThreadMessage[]; state?: ReadonlyJSONValue })
38
+ | ((
39
+ threadId: string,
40
+ ) => Promise<SwitchToThreadResult> | SwitchToThreadResult)
28
41
  | undefined;
29
42
  };
30
43
 
@@ -42,7 +55,7 @@ export type UseAgUiRuntimeAdapters = {
42
55
  };
43
56
 
44
57
  export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
45
- agent: HttpAgent;
58
+ agent: AbstractAgent;
46
59
  logger?: Partial<Logger>;
47
60
  showThinking?: boolean;
48
61
  onError?: (e: Error) => void;
@@ -74,10 +74,11 @@ export function useAgUiRuntime(
74
74
  const threadList = useMemo(() => {
75
75
  if (!threadListAdapter) return undefined;
76
76
 
77
- const { onSwitchToNewThread, onSwitchToThread } = threadListAdapter;
77
+ const { onSwitchToNewThread, onSwitchToThread, ...rest } =
78
+ threadListAdapter;
78
79
 
79
80
  return {
80
- threadId: threadListAdapter.threadId,
81
+ ...rest,
81
82
  onSwitchToNewThread: onSwitchToNewThread
82
83
  ? async () => {
83
84
  await onSwitchToNewThread();
@@ -86,11 +87,17 @@ export function useAgUiRuntime(
86
87
  : undefined,
87
88
  onSwitchToThread: onSwitchToThread
88
89
  ? async (threadId: string) => {
90
+ // Clear before the thread id flips, or the old messages leak
91
+ // into the new thread as a sibling branch.
92
+ core.applyExternalMessages([]);
89
93
  const result = await onSwitchToThread(threadId);
90
94
  core.applyExternalMessages(result.messages);
91
95
  if (result.state) {
92
96
  core.loadExternalState(result.state);
93
97
  }
98
+ if (result.unstable_resume) {
99
+ void core.resumeInFlightRun(result.messages);
100
+ }
94
101
  }
95
102
  : undefined,
96
103
  };