@assistant-ui/react-ag-ui 0.0.42 → 0.0.44

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 (36) hide show
  1. package/dist/agUiExtras.d.ts +7 -0
  2. package/dist/agUiExtras.d.ts.map +1 -0
  3. package/dist/agUiExtras.js +7 -0
  4. package/dist/agUiExtras.js.map +1 -0
  5. package/dist/hooks.d.ts +27 -0
  6. package/dist/hooks.d.ts.map +1 -0
  7. package/dist/hooks.js +35 -0
  8. package/dist/hooks.js.map +1 -0
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +2 -1
  11. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +5 -1
  12. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  13. package/dist/runtime/AgUiThreadRuntimeCore.js +62 -2
  14. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  15. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  16. package/dist/runtime/adapter/conversions.js +29 -2
  17. package/dist/runtime/adapter/conversions.js.map +1 -1
  18. package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
  19. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  20. package/dist/runtime/adapter/run-aggregator.js +24 -4
  21. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  22. package/dist/runtime/types.d.ts +7 -2
  23. package/dist/runtime/types.d.ts.map +1 -1
  24. package/dist/useAgUiRuntime.d.ts +9 -0
  25. package/dist/useAgUiRuntime.d.ts.map +1 -1
  26. package/dist/useAgUiRuntime.js +7 -0
  27. package/dist/useAgUiRuntime.js.map +1 -1
  28. package/package.json +6 -5
  29. package/src/agUiExtras.ts +5 -0
  30. package/src/hooks.ts +42 -0
  31. package/src/index.ts +5 -0
  32. package/src/runtime/AgUiThreadRuntimeCore.ts +99 -3
  33. package/src/runtime/adapter/conversions.ts +69 -0
  34. package/src/runtime/adapter/run-aggregator.ts +30 -1
  35. package/src/runtime/types.ts +12 -0
  36. package/src/useAgUiRuntime.ts +19 -0
@@ -4,6 +4,7 @@ import { generateId, fromThreadMessageLike } from "@assistant-ui/core";
4
4
  import type {
5
5
  AddToolResultOptions,
6
6
  AppendMessage,
7
+ CreateAppendMessage,
7
8
  AssistantRuntime,
8
9
  ChatModelRunOptions,
9
10
  ChatModelRunResult,
@@ -174,6 +175,12 @@ export class AgUiThreadRuntimeCore {
174
175
  async append(message: AppendMessage): Promise<void> {
175
176
  const startRun = message.startRun ?? message.role === "user";
176
177
  if (startRun) this.assertNoPendingInterrupts();
178
+ const threadMessageId = this.appendEntry(message);
179
+ if (!startRun) return;
180
+ await this.startRun(threadMessageId, message.runConfig);
181
+ }
182
+
183
+ private appendEntry(message: AppendMessage): string {
177
184
  if (message.sourceId) {
178
185
  this.messages = this.messages.filter(
179
186
  (entry) => entry.id !== message.sourceId,
@@ -185,9 +192,7 @@ export class AgUiThreadRuntimeCore {
185
192
  this.messages = [...this.messages, threadMessage];
186
193
  this.notifyUpdate();
187
194
  this.recordHistoryEntry(message.parentId ?? null, threadMessage);
188
-
189
- if (!startRun) return;
190
- await this.startRun(threadMessage.id, message.runConfig);
195
+ return threadMessage.id;
191
196
  }
192
197
 
193
198
  async edit(message: AppendMessage): Promise<void> {
@@ -348,6 +353,97 @@ export class AgUiThreadRuntimeCore {
348
353
  await this.startRun(pending.messageId, this.lastRunConfig, resume);
349
354
  }
350
355
 
356
+ async steerAway(
357
+ message: CreateAppendMessage,
358
+ responses?: readonly AgUiResumeEntry[],
359
+ ): Promise<void> {
360
+ const pending = this.getPendingInterrupts();
361
+ if (!pending) {
362
+ if (responses?.length) {
363
+ throw new Error(
364
+ "[agui] steerAway: no pending interrupts on this thread",
365
+ );
366
+ }
367
+ await this.append(this.toAppendMessage(message));
368
+ return;
369
+ }
370
+
371
+ const resume = this.resolveSteerAwayResume(pending.interrupts, responses);
372
+
373
+ if (this.isRunningFlag) {
374
+ throw new Error("[agui] steerAway: a run is already in progress");
375
+ }
376
+
377
+ const normalized = this.toAppendMessage(message);
378
+ // Clear before appendEntry so the interrupted assistant is still in
379
+ // this.messages when its status is flipped; a parentId that points at an
380
+ // ancestor would otherwise truncate it away before it can be cleared.
381
+ this.clearPendingInterrupts(pending.messageId);
382
+ const threadMessageId = this.appendEntry(normalized);
383
+ await this.startRun(threadMessageId, normalized.runConfig, resume);
384
+ }
385
+
386
+ private resolveSteerAwayResume(
387
+ interrupts: readonly AgUiInterrupt[],
388
+ responses: readonly AgUiResumeEntry[] | undefined,
389
+ ): AgUiResumeEntry[] {
390
+ const openIds = interrupts.map((interrupt) => interrupt.id);
391
+ const known = new Set(openIds);
392
+ const responsesById = new Map<string, AgUiResumeEntry>();
393
+ for (const entry of responses ?? []) {
394
+ if (!entry || typeof entry.interruptId !== "string") {
395
+ throw new Error(
396
+ "[agui] steerAway: every response must have an interruptId",
397
+ );
398
+ }
399
+ if (entry.status !== "resolved" && entry.status !== "cancelled") {
400
+ throw new Error(
401
+ `[agui] steerAway: invalid status "${entry.status}" for interrupt ${entry.interruptId}`,
402
+ );
403
+ }
404
+ if (!known.has(entry.interruptId)) {
405
+ throw new Error(
406
+ `[agui] steerAway: unknown interrupt id ${entry.interruptId}`,
407
+ );
408
+ }
409
+ if (responsesById.has(entry.interruptId)) {
410
+ throw new Error(
411
+ `[agui] steerAway: duplicate response for interrupt ${entry.interruptId}`,
412
+ );
413
+ }
414
+ responsesById.set(entry.interruptId, entry);
415
+ }
416
+ return openIds.map(
417
+ (id) => responsesById.get(id) ?? { interruptId: id, status: "cancelled" },
418
+ );
419
+ }
420
+
421
+ private toAppendMessage(message: CreateAppendMessage): AppendMessage {
422
+ if (typeof message === "string") {
423
+ return {
424
+ createdAt: new Date(),
425
+ parentId: this.messages.at(-1)?.id ?? null,
426
+ sourceId: null,
427
+ runConfig: {},
428
+ role: "user",
429
+ content: [{ type: "text", text: message }],
430
+ attachments: [],
431
+ metadata: { custom: {} },
432
+ };
433
+ }
434
+ return {
435
+ createdAt: message.createdAt ?? new Date(),
436
+ parentId: message.parentId ?? this.messages.at(-1)?.id ?? null,
437
+ sourceId: message.sourceId ?? null,
438
+ role: message.role ?? "user",
439
+ content: message.content,
440
+ attachments: message.attachments ?? [],
441
+ metadata: message.metadata ?? { custom: {} },
442
+ runConfig: message.runConfig ?? {},
443
+ startRun: message.startRun,
444
+ } as AppendMessage;
445
+ }
446
+
351
447
  private clearPendingInterrupts(messageId: string): void {
352
448
  let touched = false;
353
449
  this.messages = this.messages.map((message) => {
@@ -2,8 +2,14 @@
2
2
 
3
3
  import type { InputContent } from "@ag-ui/client";
4
4
  import type { ThreadMessageLike as CoreThreadMessageLike } from "@assistant-ui/core";
5
+ import { getAutoStatus } from "@assistant-ui/core/internal";
5
6
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
6
7
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
8
+ import {
9
+ AG_UI_METADATA_NAMESPACE,
10
+ type AgUiCustomMetadata,
11
+ } from "./run-aggregator";
12
+ import type { AgUiInterrupt } from "../types";
7
13
 
8
14
  export type { InputContent };
9
15
 
@@ -439,6 +445,28 @@ function extractAssistantToolCalls(
439
445
  return parts;
440
446
  }
441
447
 
448
+ function validateInterrupts(value: unknown): AgUiInterrupt[] | undefined {
449
+ if (!Array.isArray(value)) return undefined;
450
+ const valid = value.filter(
451
+ (entry): entry is AgUiInterrupt =>
452
+ isObject(entry) &&
453
+ typeof entry.id === "string" &&
454
+ typeof entry.reason === "string",
455
+ );
456
+ return valid.length > 0 ? valid : undefined;
457
+ }
458
+
459
+ function readPersistedInterrupts(
460
+ metadata: unknown,
461
+ ): AgUiInterrupt[] | undefined {
462
+ if (!isObject(metadata)) return undefined;
463
+ const custom = metadata.custom;
464
+ if (!isObject(custom)) return undefined;
465
+ const namespaced = custom[AG_UI_METADATA_NAMESPACE];
466
+ if (!isObject(namespaced)) return undefined;
467
+ return validateInterrupts(namespaced.interrupts);
468
+ }
469
+
442
470
  function toAssistantSnapshotMessage(
443
471
  rawMessage: Record<string, unknown>,
444
472
  ): CoreThreadMessageLike {
@@ -449,11 +477,23 @@ function toAssistantSnapshotMessage(
449
477
  ...toolCallParts,
450
478
  ];
451
479
  const messageName = getString(rawMessage, "name");
480
+ const interrupts = readPersistedInterrupts(rawMessage.metadata);
452
481
  return {
453
482
  id: getString(rawMessage, "id") ?? generateId(),
454
483
  role: "assistant",
455
484
  content: assistantContent.length > 0 ? assistantContent : "",
456
485
  ...(messageName !== undefined ? { name: messageName } : {}),
486
+ ...(interrupts
487
+ ? {
488
+ metadata: {
489
+ custom: {
490
+ [AG_UI_METADATA_NAMESPACE]: {
491
+ interrupts,
492
+ } satisfies AgUiCustomMetadata,
493
+ },
494
+ },
495
+ }
496
+ : {}),
457
497
  };
458
498
  }
459
499
 
@@ -605,6 +645,35 @@ export function fromAgUiMessages(
605
645
  }
606
646
  }
607
647
 
648
+ for (let i = 0; i < converted.length; i++) {
649
+ const message = converted[i]!;
650
+ if (message.role !== "assistant") continue;
651
+
652
+ const hasInterrupt =
653
+ readPersistedInterrupts(message.metadata) !== undefined;
654
+ const hasPendingToolCall =
655
+ Array.isArray(message.content) &&
656
+ message.content.some(
657
+ (part) =>
658
+ isObject(part) &&
659
+ part.type === "tool-call" &&
660
+ part.result === undefined,
661
+ );
662
+
663
+ if (hasInterrupt || hasPendingToolCall) {
664
+ converted[i] = {
665
+ ...message,
666
+ status: getAutoStatus(
667
+ false,
668
+ false,
669
+ hasInterrupt,
670
+ hasPendingToolCall,
671
+ undefined,
672
+ ),
673
+ };
674
+ }
675
+ }
676
+
608
677
  return converted;
609
678
  }
610
679
 
@@ -334,7 +334,7 @@ export class RunAggregator {
334
334
  (part) => part.kind === "tool-call" && part.toolCallId === id,
335
335
  )
336
336
  ) {
337
- this.partOrder.push({ kind: "tool-call", toolCallId: id });
337
+ this.insertToolPart(id, parentMessageId);
338
338
  }
339
339
  const state: ToolCallState = {
340
340
  toolCallId: id,
@@ -350,6 +350,35 @@ export class RunAggregator {
350
350
  this.toolCalls.set(id, state);
351
351
  }
352
352
 
353
+ // The message and tool-call channels are unordered on the wire, so anchor a
354
+ // tool call under its parentMessageId text part instead of appending it.
355
+ private insertToolPart(id: string, parentMessageId?: string) {
356
+ const entry = { kind: "tool-call", toolCallId: id } as const;
357
+ if (parentMessageId) {
358
+ const parentIndex = this.partOrder.findIndex(
359
+ (part) => part.kind === "text" && part.key === parentMessageId,
360
+ );
361
+ if (parentIndex !== -1) {
362
+ let insertAt = parentIndex + 1;
363
+ while (insertAt < this.partOrder.length) {
364
+ const part = this.partOrder[insertAt];
365
+ if (
366
+ part?.kind === "tool-call" &&
367
+ this.toolCalls.get(part.toolCallId)?.parentMessageId ===
368
+ parentMessageId
369
+ ) {
370
+ insertAt += 1;
371
+ continue;
372
+ }
373
+ break;
374
+ }
375
+ this.partOrder.splice(insertAt, 0, entry);
376
+ return;
377
+ }
378
+ }
379
+ this.partOrder.push(entry);
380
+ }
381
+
353
382
  private appendToolArgs(id: string | undefined, delta: string) {
354
383
  const entry = id ? this.toolCalls.get(id) : undefined;
355
384
  if (!entry) return;
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ CreateAppendMessage,
2
3
  AttachmentAdapter,
3
4
  DictationAdapter,
4
5
  ExternalStoreSharedOptions,
@@ -85,6 +86,17 @@ export type AgUiResumeEntry = {
85
86
  payload?: unknown;
86
87
  };
87
88
 
89
+ export type AgUiRuntimeExtras = {
90
+ interrupts: readonly AgUiInterrupt[];
91
+ submitInterruptResponses: (
92
+ responses: readonly AgUiResumeEntry[],
93
+ ) => Promise<void>;
94
+ steerAway: (
95
+ message: CreateAppendMessage,
96
+ responses?: readonly AgUiResumeEntry[],
97
+ ) => Promise<void>;
98
+ };
99
+
88
100
  export type AgUiRunFinishedOutcome =
89
101
  | { type: "success" }
90
102
  | { type: "interrupt"; interrupts: AgUiInterrupt[] };
@@ -21,9 +21,21 @@ import type {
21
21
  UseAgUiRuntimeOptions,
22
22
  } from "./runtime/types";
23
23
  import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
24
+ import { agUiExtras } from "./agUiExtras";
25
+
26
+ const EMPTY_INTERRUPTS: readonly AgUiInterrupt[] = [];
24
27
 
25
28
  export type AgUiAssistantRuntime = AssistantRuntime & {
29
+ /**
30
+ * @deprecated Use the `useAgUiInterrupts()` hook instead. This method is kept
31
+ * for backward compatibility and will be removed in a future major release.
32
+ */
26
33
  unstable_getPendingInterrupts: () => readonly AgUiInterrupt[];
34
+ /**
35
+ * @deprecated Use the `useAgUiSubmitInterruptResponses()` hook instead. This
36
+ * method is kept for backward compatibility and will be removed in a future
37
+ * major release.
38
+ */
27
39
  unstable_submitInterruptResponses: (
28
40
  responses: readonly AgUiResumeEntry[],
29
41
  ) => Promise<void>;
@@ -127,6 +139,13 @@ export function useAgUiRuntime(
127
139
  messages: core.getMessages(),
128
140
  state: core.getState(),
129
141
  isRunning: core.isRunning() || hasExecutingTools,
142
+ extras: agUiExtras.provide({
143
+ interrupts:
144
+ core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,
145
+ submitInterruptResponses: (responses) =>
146
+ core.submitInterruptResponses(responses),
147
+ steerAway: (message, responses) => core.steerAway(message, responses),
148
+ }),
130
149
  unstable_enableToolInvocations: true,
131
150
  setToolStatuses,
132
151
  onNew: (message: AppendMessage) => core.append(message),