@assistant-ui/react-ag-ui 0.0.41 → 0.0.43

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 +63 -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 +10 -1
  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 +8 -1
  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 +103 -3
  33. package/src/runtime/adapter/conversions.ts +20 -0
  34. package/src/runtime/adapter/run-aggregator.ts +30 -1
  35. package/src/runtime/types.ts +12 -0
  36. package/src/useAgUiRuntime.ts +20 -1
@@ -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,
@@ -141,6 +142,10 @@ export class AgUiThreadRuntimeCore {
141
142
  const messages = repo.messages.map((item) => item.message);
142
143
  this.applyExternalMessages(messages);
143
144
 
145
+ if (repo.state !== undefined) {
146
+ this.loadExternalState(repo.state);
147
+ }
148
+
144
149
  if (repo.unstable_resume) {
145
150
  const parentId = repo.headId ?? messages.at(-1)?.id ?? null;
146
151
  const resumeStream = this.history?.resume?.bind(this.history);
@@ -170,6 +175,12 @@ export class AgUiThreadRuntimeCore {
170
175
  async append(message: AppendMessage): Promise<void> {
171
176
  const startRun = message.startRun ?? message.role === "user";
172
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 {
173
184
  if (message.sourceId) {
174
185
  this.messages = this.messages.filter(
175
186
  (entry) => entry.id !== message.sourceId,
@@ -181,9 +192,7 @@ export class AgUiThreadRuntimeCore {
181
192
  this.messages = [...this.messages, threadMessage];
182
193
  this.notifyUpdate();
183
194
  this.recordHistoryEntry(message.parentId ?? null, threadMessage);
184
-
185
- if (!startRun) return;
186
- await this.startRun(threadMessage.id, message.runConfig);
195
+ return threadMessage.id;
187
196
  }
188
197
 
189
198
  async edit(message: AppendMessage): Promise<void> {
@@ -344,6 +353,97 @@ export class AgUiThreadRuntimeCore {
344
353
  await this.startRun(pending.messageId, this.lastRunConfig, resume);
345
354
  }
346
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
+
347
447
  private clearPendingInterrupts(messageId: string): void {
348
448
  let touched = false;
349
449
  this.messages = this.messages.map((message) => {
@@ -2,6 +2,7 @@
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";
7
8
 
@@ -605,6 +606,25 @@ export function fromAgUiMessages(
605
606
  }
606
607
  }
607
608
 
609
+ for (let i = 0; i < converted.length; i++) {
610
+ const message = converted[i]!;
611
+ if (message.role !== "assistant" || !Array.isArray(message.content)) {
612
+ continue;
613
+ }
614
+ const hasPendingToolCall = message.content.some(
615
+ (part) =>
616
+ isObject(part) &&
617
+ part.type === "tool-call" &&
618
+ part.result === undefined,
619
+ );
620
+ if (hasPendingToolCall) {
621
+ converted[i] = {
622
+ ...message,
623
+ status: getAutoStatus(false, false, false, true, undefined),
624
+ };
625
+ }
626
+ }
627
+
608
628
  return converted;
609
629
  }
610
630
 
@@ -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>;
@@ -92,7 +104,7 @@ export function useAgUiRuntime(
92
104
  core.applyExternalMessages([]);
93
105
  const result = await onSwitchToThread(threadId);
94
106
  core.applyExternalMessages(result.messages);
95
- if (result.state) {
107
+ if (result.state !== undefined) {
96
108
  core.loadExternalState(result.state);
97
109
  }
98
110
  if (result.unstable_resume) {
@@ -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),