@assistant-ui/react-ag-ui 0.0.29 → 0.0.31

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 (33) hide show
  1. package/README.md +41 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +11 -0
  5. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  6. package/dist/runtime/AgUiThreadRuntimeCore.js +186 -12
  7. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  8. package/dist/runtime/adapter/run-aggregator.d.ts +18 -5
  9. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.js +134 -39
  11. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  12. package/dist/runtime/adapter/subscriber.d.ts +5 -0
  13. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  14. package/dist/runtime/adapter/subscriber.js +55 -36
  15. package/dist/runtime/adapter/subscriber.js.map +1 -1
  16. package/dist/runtime/event-parser.d.ts +5 -1
  17. package/dist/runtime/event-parser.d.ts.map +1 -1
  18. package/dist/runtime/event-parser.js +48 -2
  19. package/dist/runtime/event-parser.js.map +1 -1
  20. package/dist/runtime/types.d.ts +22 -0
  21. package/dist/runtime/types.d.ts.map +1 -1
  22. package/dist/useAgUiRuntime.d.ts +6 -2
  23. package/dist/useAgUiRuntime.d.ts.map +1 -1
  24. package/dist/useAgUiRuntime.js +7 -1
  25. package/dist/useAgUiRuntime.js.map +1 -1
  26. package/package.json +4 -4
  27. package/src/index.ts +5 -0
  28. package/src/runtime/AgUiThreadRuntimeCore.ts +251 -11
  29. package/src/runtime/adapter/run-aggregator.ts +168 -44
  30. package/src/runtime/adapter/subscriber.ts +53 -43
  31. package/src/runtime/event-parser.ts +64 -3
  32. package/src/runtime/types.ts +31 -1
  33. package/src/useAgUiRuntime.ts +23 -3
@@ -2,12 +2,19 @@
2
2
 
3
3
  import type {
4
4
  ChatModelRunResult,
5
+ MessageTiming,
5
6
  ThreadAssistantMessagePart,
6
7
  ToolCallMessagePart,
7
8
  } from "@assistant-ui/core";
8
- import type { AgUiEvent } from "../types";
9
+ import type { AgUiEvent, AgUiInterrupt } from "../types";
9
10
  import type { Logger } from "../logger";
10
11
 
12
+ export const AG_UI_METADATA_NAMESPACE = "agui";
13
+
14
+ export type AgUiCustomMetadata = {
15
+ interrupts?: AgUiInterrupt[];
16
+ };
17
+
11
18
  type Emit = (update: ChatModelRunResult) => void;
12
19
 
13
20
  type ToolCallState = {
@@ -18,12 +25,14 @@ type ToolCallState = {
18
25
  result: unknown;
19
26
  isError: boolean | undefined;
20
27
  parentMessageId?: string;
28
+ toolMessageId?: string;
21
29
  };
22
30
 
23
31
  export type RunAggregatorOptions = {
24
32
  showThinking: boolean;
25
33
  logger: Logger;
26
34
  emit: Emit;
35
+ onServerMessageId?: (messageId: string) => void;
27
36
  };
28
37
 
29
38
  /**
@@ -36,53 +45,75 @@ export class RunAggregator {
36
45
  private readonly emitUpdate: Emit;
37
46
  private readonly showThinking: boolean;
38
47
  private readonly logger: Logger;
48
+ private readonly onServerMessageId: ((messageId: string) => void) | undefined;
39
49
 
40
50
  private status: ChatModelRunResult["status"] | undefined;
51
+ private interrupts: AgUiInterrupt[] | undefined;
41
52
  private readonly textParts = new Map<
42
53
  string,
43
54
  { buffer: string; touched: boolean }
44
55
  >();
45
56
  private activeTextMessageId: string | undefined;
46
- private reasoningBuffer = "";
47
- private reasoningActive = false;
57
+ private readonly reasoningParts = new Map<string, string>(); // key → buffer
58
+ private activeReasoningKey: string | undefined;
59
+ private reasoningPartCounter = 0;
48
60
  private readonly toolCalls = new Map<string, ToolCallState>();
49
61
  private readonly partOrder: (
50
62
  | { kind: "text"; key: string }
51
- | { kind: "reasoning" }
63
+ | { kind: "reasoning"; key: string }
52
64
  | { kind: "tool-call"; toolCallId: string }
53
65
  )[] = [];
54
- private hasReasoningPart = false;
55
66
  private textPartCounter = 0;
67
+ private serverMessageIdReported = false;
68
+
69
+ private streamStartTime: number | undefined;
70
+ private firstTokenTime: number | undefined;
71
+ private totalChunks = 0;
56
72
 
57
73
  constructor(options: RunAggregatorOptions) {
58
74
  this.emitUpdate = options.emit;
59
75
  this.showThinking = options.showThinking;
60
76
  this.logger = options.logger;
77
+ this.onServerMessageId = options.onServerMessageId;
61
78
  }
62
79
 
63
80
  handle(event: AgUiEvent): void {
64
81
  switch (event.type) {
65
82
  case "RUN_STARTED": {
66
83
  this.clearTextParts();
67
- this.reasoningBuffer = "";
68
- this.reasoningActive = false;
84
+ this.reasoningParts.clear();
85
+ this.activeReasoningKey = undefined;
86
+ this.reasoningPartCounter = 0;
69
87
  this.toolCalls.clear();
70
88
  this.partOrder.length = 0;
71
- this.hasReasoningPart = false;
72
89
  this.textPartCounter = 0;
73
90
  this.activeTextMessageId = undefined;
91
+ this.interrupts = undefined;
92
+ this.serverMessageIdReported = false;
93
+ this.streamStartTime = Date.now();
94
+ this.firstTokenTime = undefined;
95
+ this.totalChunks = 0;
74
96
  this.status = { type: "running" };
75
97
  this.emit();
76
98
  break;
77
99
  }
78
100
  case "RUN_FINISHED": {
101
+ if (event.outcome?.type === "interrupt") {
102
+ this.interrupts = event.outcome.interrupts;
103
+ this.status = { type: "requires-action", reason: "interrupt" };
104
+ this.emit();
105
+ break;
106
+ }
107
+
108
+ this.interrupts = undefined;
79
109
  const hasUnresolvedToolCalls = Array.from(this.toolCalls.values()).some(
80
110
  (tc) => tc.result === undefined,
81
111
  );
82
112
 
83
- this.status = hasUnresolvedToolCalls
84
- ? { type: "requires-action", reason: "tool-calls" }
85
- : { type: "complete", reason: "unknown" };
113
+ this.status =
114
+ event.outcome?.type === "success" || !hasUnresolvedToolCalls
115
+ ? { type: "complete", reason: "unknown" }
116
+ : { type: "requires-action", reason: "tool-calls" };
86
117
  this.emit();
87
118
  break;
88
119
  }
@@ -102,6 +133,7 @@ export class RunAggregator {
102
133
  }
103
134
 
104
135
  case "TEXT_MESSAGE_START": {
136
+ this.reportServerMessageId(event.messageId);
105
137
  const id = this.startTextMessage(event.messageId);
106
138
  if (id) {
107
139
  this.markTextPartTouched(id);
@@ -111,15 +143,18 @@ export class RunAggregator {
111
143
  }
112
144
  case "TEXT_MESSAGE_CONTENT":
113
145
  case "TEXT_MESSAGE_CHUNK": {
146
+ const incomingId = "messageId" in event ? event.messageId : undefined;
147
+ this.reportServerMessageId(incomingId);
114
148
  if (!event.delta) break;
115
- const id = this.resolveTextMessageId(
116
- "messageId" in event ? event.messageId : undefined,
117
- );
149
+ this.recordFirstToken();
150
+ const id = this.resolveTextMessageId(incomingId);
118
151
  this.appendText(id, event.delta);
152
+ this.totalChunks++;
119
153
  this.emit();
120
154
  break;
121
155
  }
122
156
  case "TEXT_MESSAGE_END": {
157
+ this.reportServerMessageId(event.messageId);
123
158
  if (event.messageId && this.activeTextMessageId === event.messageId) {
124
159
  this.activeTextMessageId = undefined;
125
160
  }
@@ -131,11 +166,22 @@ export class RunAggregator {
131
166
  case "THINKING_TEXT_MESSAGE_START":
132
167
  case "REASONING_START":
133
168
  case "REASONING_MESSAGE_START":
134
- this.handleReasoningStart();
169
+ this.handleReasoningStart(
170
+ "messageId" in event ? event.messageId : undefined,
171
+ );
135
172
  break;
136
173
  case "THINKING_TEXT_MESSAGE_CONTENT":
137
- case "REASONING_MESSAGE_CONTENT":
138
174
  this.handleReasoningContent(event.delta);
175
+ this.totalChunks++;
176
+ this.recordFirstToken();
177
+ break;
178
+ case "REASONING_MESSAGE_CONTENT":
179
+ this.handleReasoningContent(
180
+ event.delta,
181
+ "messageId" in event ? event.messageId : undefined,
182
+ );
183
+ this.totalChunks++;
184
+ this.recordFirstToken();
139
185
  break;
140
186
  case "THINKING_TEXT_MESSAGE_END":
141
187
  case "THINKING_END":
@@ -145,6 +191,7 @@ export class RunAggregator {
145
191
  break;
146
192
 
147
193
  case "TOOL_CALL_START": {
194
+ this.reportServerMessageId(event.parentMessageId);
148
195
  this.startToolCall(
149
196
  event.toolCallId,
150
197
  event.toolCallName,
@@ -155,6 +202,9 @@ export class RunAggregator {
155
202
  }
156
203
  case "TOOL_CALL_ARGS":
157
204
  case "TOOL_CALL_CHUNK": {
205
+ if (event.type === "TOOL_CALL_CHUNK") {
206
+ this.reportServerMessageId(event.parentMessageId);
207
+ }
158
208
  if (!event.delta) break;
159
209
  this.appendToolArgs(event.toolCallId, event.delta);
160
210
  this.emit();
@@ -169,6 +219,7 @@ export class RunAggregator {
169
219
  event.toolCallId,
170
220
  event.content ?? "",
171
221
  event.role === "tool" ? false : undefined,
222
+ event.messageId,
172
223
  );
173
224
  this.emit();
174
225
  break;
@@ -180,6 +231,12 @@ export class RunAggregator {
180
231
  }
181
232
  }
182
233
 
234
+ private reportServerMessageId(messageId: string | undefined): void {
235
+ if (this.serverMessageIdReported || !messageId) return;
236
+ this.serverMessageIdReported = true;
237
+ this.onServerMessageId?.(messageId);
238
+ }
239
+
183
240
  private clearTextParts(): void {
184
241
  this.textParts.clear();
185
242
  }
@@ -244,6 +301,9 @@ export class RunAggregator {
244
301
  parentMessageId?: string,
245
302
  ) {
246
303
  if (!id) return;
304
+ // A new tool call acts as a boundary: any anonymous text that arrives
305
+ // after it should be a new part, not appended to the pre-tool text.
306
+ this.activeTextMessageId = undefined;
247
307
  if (
248
308
  !this.partOrder.some(
249
309
  (part) => part.kind === "tool-call" && part.toolCallId === id,
@@ -281,7 +341,12 @@ export class RunAggregator {
281
341
  }
282
342
  }
283
343
 
284
- private finishToolCall(id: string, content: string, isError?: boolean) {
344
+ private finishToolCall(
345
+ id: string,
346
+ content: string,
347
+ isError?: boolean,
348
+ toolMessageId?: string,
349
+ ) {
285
350
  if (!id) return;
286
351
  let entry = this.toolCalls.get(id);
287
352
  if (!entry) {
@@ -304,6 +369,9 @@ export class RunAggregator {
304
369
  }
305
370
  entry.result = this.tryParseJSON(content);
306
371
  entry.isError = isError;
372
+ if (toolMessageId) {
373
+ entry.toolMessageId = toolMessageId;
374
+ }
307
375
  }
308
376
 
309
377
  private tryParseJSON(value: string): unknown {
@@ -320,14 +388,11 @@ export class RunAggregator {
320
388
 
321
389
  for (const part of this.partOrder) {
322
390
  if (part.kind === "reasoning") {
323
- if (
324
- this.showThinking &&
325
- (this.reasoningActive || this.reasoningBuffer.length > 0)
326
- ) {
327
- snapshot.push({
328
- type: "reasoning",
329
- text: this.reasoningBuffer,
330
- } as const);
391
+ if (this.showThinking) {
392
+ const buffer = this.reasoningParts.get(part.key) ?? "";
393
+ if (buffer.length > 0 || this.activeReasoningKey === part.key) {
394
+ snapshot.push({ type: "reasoning", text: buffer } as const);
395
+ }
331
396
  }
332
397
  continue;
333
398
  }
@@ -351,45 +416,104 @@ export class RunAggregator {
351
416
  ...(entry.result !== undefined ? { result: entry.result } : {}),
352
417
  ...(entry.isError !== undefined ? { isError: entry.isError } : {}),
353
418
  ...(entry.parentMessageId ? { parentId: entry.parentMessageId } : {}),
354
- } as ToolCallMessagePart;
419
+ ...(entry.toolMessageId
420
+ ? { unstable_toolMessageId: entry.toolMessageId }
421
+ : {}),
422
+ } as ToolCallMessagePart & { unstable_toolMessageId?: string };
355
423
  snapshot.push(toolPart);
356
424
  }
357
425
 
426
+ const timing = this.getTiming();
427
+ const metadata = {
428
+ ...(timing ? { timing } : {}),
429
+ ...(this.interrupts
430
+ ? {
431
+ custom: {
432
+ [AG_UI_METADATA_NAMESPACE]: {
433
+ interrupts: this.interrupts,
434
+ } satisfies AgUiCustomMetadata,
435
+ },
436
+ }
437
+ : {}),
438
+ };
358
439
  const result: ChatModelRunResult = {
359
440
  content: snapshot,
360
441
  ...(this.status ? { status: this.status } : undefined),
442
+ ...(Object.keys(metadata).length > 0 ? { metadata } : undefined),
361
443
  };
362
444
  this.emitUpdate(result);
363
445
  }
364
446
 
365
- private handleReasoningStart(): void {
447
+ private recordFirstToken(): void {
448
+ if (
449
+ this.firstTokenTime === undefined &&
450
+ this.streamStartTime !== undefined
451
+ ) {
452
+ this.firstTokenTime = Date.now() - this.streamStartTime;
453
+ }
454
+ }
455
+
456
+ private getTiming(): MessageTiming | undefined {
457
+ if (this.streamStartTime === undefined) return undefined;
458
+
459
+ const now = Date.now();
460
+ const totalStreamTime = now - this.streamStartTime;
461
+ const tokenCount =
462
+ this.totalChunks > 0
463
+ ? Math.ceil(
464
+ Array.from(this.textParts.values()).reduce(
465
+ (sum, p) => sum + p.buffer.length,
466
+ 0,
467
+ ) / 4,
468
+ )
469
+ : undefined;
470
+ const tokensPerSecond =
471
+ tokenCount && totalStreamTime > 0
472
+ ? (tokenCount / totalStreamTime) * 1000
473
+ : undefined;
474
+
475
+ return {
476
+ streamStartTime: this.streamStartTime,
477
+ ...(this.firstTokenTime !== undefined
478
+ ? { firstTokenTime: this.firstTokenTime }
479
+ : {}),
480
+ totalStreamTime,
481
+ ...(tokenCount !== undefined ? { tokenCount } : {}),
482
+ ...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}),
483
+ totalChunks: this.totalChunks,
484
+ toolCallCount: this.toolCalls.size,
485
+ };
486
+ }
487
+
488
+ private handleReasoningStart(messageId?: string): void {
366
489
  if (!this.showThinking) return;
367
- this.reasoningActive = true;
368
- this.ensureReasoningPart();
490
+ // A reasoning block acts as a boundary: anonymous text arriving after it
491
+ // should be a new part, not appended to any pre-reasoning text.
492
+ this.activeTextMessageId = undefined;
493
+ const key = messageId ?? `__auto-reasoning-${++this.reasoningPartCounter}`;
494
+ if (!this.reasoningParts.has(key)) {
495
+ this.reasoningParts.set(key, "");
496
+ this.partOrder.push({ kind: "reasoning", key });
497
+ }
498
+ this.activeReasoningKey = key;
369
499
  this.emit();
370
500
  }
371
501
 
372
- private handleReasoningContent(delta: string): void {
502
+ private handleReasoningContent(delta: string, messageId?: string): void {
373
503
  if (!this.showThinking || !delta) return;
374
- this.reasoningBuffer += delta;
375
- this.ensureReasoningPart();
504
+ if (!this.activeReasoningKey) {
505
+ // Content arrived without a preceding START — create the slot lazily.
506
+ this.handleReasoningStart(messageId);
507
+ }
508
+ const key = this.activeReasoningKey;
509
+ if (!key) return;
510
+ this.reasoningParts.set(key, (this.reasoningParts.get(key) ?? "") + delta);
376
511
  this.emit();
377
512
  }
378
513
 
379
514
  private handleReasoningEnd(): void {
380
515
  if (!this.showThinking) return;
516
+ this.activeReasoningKey = undefined;
381
517
  this.emit();
382
518
  }
383
-
384
- private ensureReasoningPart(): void {
385
- if (this.hasReasoningPart) return;
386
- // ensure reasoning appears before the first text segment if possible
387
- const textIndex = this.partOrder.findIndex((part) => part.kind === "text");
388
- if (textIndex === -1) {
389
- this.partOrder.push({ kind: "reasoning" });
390
- } else {
391
- this.partOrder.splice(textIndex, 0, { kind: "reasoning" });
392
- }
393
- this.hasReasoningPart = true;
394
- }
395
519
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { AgUiEvent } from "../types";
4
4
  import { parseAgUiEvent } from "../event-parser";
5
+ import type { Logger } from "../logger";
5
6
 
6
7
  type Dispatch = (event: AgUiEvent) => void;
7
8
 
@@ -31,6 +32,7 @@ type Subscriber = {
31
32
  onMessagesSnapshotEvent?: (payload: { event: unknown }) => void;
32
33
  onCustomEvent?: (payload: { event: unknown }) => void;
33
34
  onRawEvent?: (payload: { event: unknown }) => void;
35
+ onRunFinishedEvent?: (payload: { event: unknown }) => void;
34
36
  onRunFinalized?: () => void;
35
37
  onRunFailed?: (payload: { error: Error }) => void;
36
38
  };
@@ -38,37 +40,40 @@ type Subscriber = {
38
40
  const ensureEvent = (
39
41
  raw: unknown,
40
42
  type: AgUiEvent["type"],
43
+ logger?: Logger,
41
44
  ): AgUiEvent | null => {
45
+ const parseOptions = logger ? { logger } : undefined;
46
+ let parsed: AgUiEvent | null;
42
47
  if (raw && typeof raw === "object") {
43
48
  const payload = raw as Record<string, unknown>;
44
49
  if (typeof payload.type === "string") {
45
- return parseAgUiEvent(payload);
50
+ parsed = parseAgUiEvent(payload, parseOptions);
51
+ } else {
52
+ parsed = parseAgUiEvent({ type, ...payload }, parseOptions);
46
53
  }
47
- return parseAgUiEvent({ type, ...payload });
54
+ } else {
55
+ parsed = parseAgUiEvent({ type }, parseOptions);
48
56
  }
49
- return parseAgUiEvent({ type });
50
- };
51
-
52
- const dispatchIfValid = (
53
- dispatch: Dispatch,
54
- raw: unknown,
55
- type: AgUiEvent["type"],
56
- ) => {
57
- const event = ensureEvent(raw, type);
58
- if (!event) return;
59
- dispatch(event);
57
+ return parsed && parsed.type === type ? parsed : null;
60
58
  };
61
59
 
62
60
  type SubscriberOptions = {
63
61
  dispatch: Dispatch;
64
62
  runId: string;
63
+ logger?: Logger;
65
64
  onRunFailed?: (error: Error) => void;
66
65
  };
67
66
 
68
67
  export const createAgUiSubscriber = (
69
68
  options: SubscriberOptions,
70
69
  ): Subscriber => {
71
- const { dispatch, runId, onRunFailed } = options;
70
+ const { dispatch, runId, logger, onRunFailed } = options;
71
+ let runFinishedDispatched = false;
72
+ const dispatchIfValid = (raw: unknown, type: AgUiEvent["type"]) => {
73
+ const event = ensureEvent(raw, type, logger);
74
+ if (!event) return;
75
+ dispatch(event);
76
+ };
72
77
  return {
73
78
  onEvent: ({ event }) => {
74
79
  const typeCandidate =
@@ -83,52 +88,57 @@ export const createAgUiSubscriber = (
83
88
  if (parsed) dispatch(parsed);
84
89
  },
85
90
  onTextMessageStartEvent: ({ event }) =>
86
- dispatchIfValid(dispatch, event, "TEXT_MESSAGE_START"),
91
+ dispatchIfValid(event, "TEXT_MESSAGE_START"),
87
92
  onTextMessageContentEvent: ({ event }) =>
88
- dispatchIfValid(dispatch, event, "TEXT_MESSAGE_CONTENT"),
93
+ dispatchIfValid(event, "TEXT_MESSAGE_CONTENT"),
89
94
  onTextMessageEndEvent: ({ event }) =>
90
- dispatchIfValid(dispatch, event, "TEXT_MESSAGE_END"),
95
+ dispatchIfValid(event, "TEXT_MESSAGE_END"),
91
96
  onTextMessageChunkEvent: ({ event }) =>
92
- dispatchIfValid(dispatch, event, "TEXT_MESSAGE_CHUNK"),
97
+ dispatchIfValid(event, "TEXT_MESSAGE_CHUNK"),
93
98
  onThinkingStartEvent: ({ event }) =>
94
- dispatchIfValid(dispatch, event, "THINKING_START"),
95
- onThinkingEndEvent: ({ event }) =>
96
- dispatchIfValid(dispatch, event, "THINKING_END"),
99
+ dispatchIfValid(event, "THINKING_START"),
100
+ onThinkingEndEvent: ({ event }) => dispatchIfValid(event, "THINKING_END"),
97
101
  onThinkingTextMessageStartEvent: ({ event }) =>
98
- dispatchIfValid(dispatch, event, "THINKING_TEXT_MESSAGE_START"),
102
+ dispatchIfValid(event, "THINKING_TEXT_MESSAGE_START"),
99
103
  onThinkingTextMessageContentEvent: ({ event }) =>
100
- dispatchIfValid(dispatch, event, "THINKING_TEXT_MESSAGE_CONTENT"),
104
+ dispatchIfValid(event, "THINKING_TEXT_MESSAGE_CONTENT"),
101
105
  onThinkingTextMessageEndEvent: ({ event }) =>
102
- dispatchIfValid(dispatch, event, "THINKING_TEXT_MESSAGE_END"),
106
+ dispatchIfValid(event, "THINKING_TEXT_MESSAGE_END"),
103
107
  onReasoningStartEvent: ({ event }) =>
104
- dispatchIfValid(dispatch, event, "REASONING_START"),
105
- onReasoningEndEvent: ({ event }) =>
106
- dispatchIfValid(dispatch, event, "REASONING_END"),
108
+ dispatchIfValid(event, "REASONING_START"),
109
+ onReasoningEndEvent: ({ event }) => dispatchIfValid(event, "REASONING_END"),
107
110
  onReasoningMessageStartEvent: ({ event }) =>
108
- dispatchIfValid(dispatch, event, "REASONING_MESSAGE_START"),
111
+ dispatchIfValid(event, "REASONING_MESSAGE_START"),
109
112
  onReasoningMessageContentEvent: ({ event }) =>
110
- dispatchIfValid(dispatch, event, "REASONING_MESSAGE_CONTENT"),
113
+ dispatchIfValid(event, "REASONING_MESSAGE_CONTENT"),
111
114
  onReasoningMessageEndEvent: ({ event }) =>
112
- dispatchIfValid(dispatch, event, "REASONING_MESSAGE_END"),
115
+ dispatchIfValid(event, "REASONING_MESSAGE_END"),
113
116
  onToolCallStartEvent: ({ event }) =>
114
- dispatchIfValid(dispatch, event, "TOOL_CALL_START"),
117
+ dispatchIfValid(event, "TOOL_CALL_START"),
115
118
  onToolCallArgsEvent: ({ event }) =>
116
- dispatchIfValid(dispatch, event, "TOOL_CALL_ARGS"),
117
- onToolCallEndEvent: ({ event }) =>
118
- dispatchIfValid(dispatch, event, "TOOL_CALL_END"),
119
+ dispatchIfValid(event, "TOOL_CALL_ARGS"),
120
+ onToolCallEndEvent: ({ event }) => dispatchIfValid(event, "TOOL_CALL_END"),
119
121
  onToolCallChunkEvent: ({ event }) =>
120
- dispatchIfValid(dispatch, event, "TOOL_CALL_CHUNK"),
122
+ dispatchIfValid(event, "TOOL_CALL_CHUNK"),
121
123
  onToolCallResultEvent: ({ event }) =>
122
- dispatchIfValid(dispatch, event, "TOOL_CALL_RESULT"),
124
+ dispatchIfValid(event, "TOOL_CALL_RESULT"),
123
125
  onStateSnapshotEvent: ({ event }) =>
124
- dispatchIfValid(dispatch, event, "STATE_SNAPSHOT"),
125
- onStateDeltaEvent: ({ event }) =>
126
- dispatchIfValid(dispatch, event, "STATE_DELTA"),
126
+ dispatchIfValid(event, "STATE_SNAPSHOT"),
127
+ onStateDeltaEvent: ({ event }) => dispatchIfValid(event, "STATE_DELTA"),
127
128
  onMessagesSnapshotEvent: ({ event }) =>
128
- dispatchIfValid(dispatch, event, "MESSAGES_SNAPSHOT"),
129
- onCustomEvent: ({ event }) => dispatchIfValid(dispatch, event, "CUSTOM"),
130
- onRawEvent: ({ event }) => dispatchIfValid(dispatch, event, "RAW"),
131
- onRunFinalized: () => dispatch({ type: "RUN_FINISHED", runId }),
129
+ dispatchIfValid(event, "MESSAGES_SNAPSHOT"),
130
+ onCustomEvent: ({ event }) => dispatchIfValid(event, "CUSTOM"),
131
+ onRawEvent: ({ event }) => dispatchIfValid(event, "RAW"),
132
+ onRunFinishedEvent: ({ event }) => {
133
+ const parsed = ensureEvent(event, "RUN_FINISHED", logger);
134
+ if (!parsed) return;
135
+ runFinishedDispatched = true;
136
+ dispatch(parsed);
137
+ },
138
+ onRunFinalized: () => {
139
+ if (runFinishedDispatched) return;
140
+ dispatch({ type: "RUN_FINISHED", runId });
141
+ },
132
142
  onRunFailed: ({ error }) => {
133
143
  onRunFailed?.(error);
134
144
  const message =
@@ -1,4 +1,9 @@
1
- import type { AgUiEvent } from "./types";
1
+ import type { AgUiEvent, AgUiInterrupt, AgUiRunFinishedOutcome } from "./types";
2
+ import type { Logger } from "./logger";
3
+
4
+ export type ParseAgUiEventOptions = {
5
+ logger?: Logger;
6
+ };
2
7
 
3
8
  const isString = (value: unknown): value is string => typeof value === "string";
4
9
  const isNonEmptyString = (value: unknown): value is string =>
@@ -16,7 +21,57 @@ const withOptional = <T extends object>(
16
21
  : ({ ...base, ...Object.fromEntries(definedEntries) } as T);
17
22
  };
18
23
 
19
- export const parseAgUiEvent = (event: unknown): AgUiEvent | null => {
24
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
25
+ !!value && typeof value === "object" && !Array.isArray(value);
26
+
27
+ const parseInterrupt = (raw: unknown): AgUiInterrupt | null => {
28
+ if (!isPlainObject(raw)) return null;
29
+ const id = raw.id;
30
+ const reason = raw.reason;
31
+ if (typeof id !== "string" || typeof reason !== "string") return null;
32
+ const interrupt: AgUiInterrupt = { id, reason };
33
+ if (typeof raw.message === "string") interrupt.message = raw.message;
34
+ if (typeof raw.toolCallId === "string") interrupt.toolCallId = raw.toolCallId;
35
+ if (typeof raw.expiresAt === "string") interrupt.expiresAt = raw.expiresAt;
36
+ if (isPlainObject(raw.responseSchema))
37
+ interrupt.responseSchema = raw.responseSchema;
38
+ if (isPlainObject(raw.metadata)) interrupt.metadata = raw.metadata;
39
+ return interrupt;
40
+ };
41
+
42
+ const parseRunFinishedOutcome = (
43
+ raw: unknown,
44
+ logger: Logger | undefined,
45
+ ): AgUiRunFinishedOutcome | undefined => {
46
+ if (!isPlainObject(raw)) return undefined;
47
+ if (raw.type === "success") return { type: "success" };
48
+ if (raw.type === "interrupt") {
49
+ if (!Array.isArray(raw.interrupts)) {
50
+ logger?.debug?.(
51
+ "[agui] RUN_FINISHED interrupt outcome missing interrupts array",
52
+ raw,
53
+ );
54
+ return undefined;
55
+ }
56
+ const parsed = raw.interrupts
57
+ .map((entry) => parseInterrupt(entry))
58
+ .filter((entry): entry is AgUiInterrupt => entry !== null);
59
+ if (parsed.length === 0) {
60
+ logger?.debug?.(
61
+ "[agui] RUN_FINISHED interrupt outcome has no valid interrupts",
62
+ raw.interrupts,
63
+ );
64
+ return undefined;
65
+ }
66
+ return { type: "interrupt", interrupts: parsed };
67
+ }
68
+ return undefined;
69
+ };
70
+
71
+ export const parseAgUiEvent = (
72
+ event: unknown,
73
+ options?: ParseAgUiEventOptions,
74
+ ): AgUiEvent | null => {
20
75
  if (!event || typeof event !== "object") return null;
21
76
  const payload = event as Record<string, unknown>;
22
77
  const typeValue = payload.type;
@@ -32,7 +87,13 @@ export const parseAgUiEvent = (event: unknown): AgUiEvent | null => {
32
87
  }
33
88
  case "RUN_FINISHED": {
34
89
  const runId = getString("runId");
35
- return runId ? { type: "RUN_FINISHED", runId } : null;
90
+ if (!runId) return null;
91
+ return withOptional(
92
+ { type: "RUN_FINISHED" as const, runId },
93
+ {
94
+ outcome: parseRunFinishedOutcome(payload.outcome, options?.logger),
95
+ },
96
+ );
36
97
  }
37
98
  case "RUN_CANCELLED": {
38
99
  const runId = getString("runId");
@@ -47,9 +47,39 @@ export type UseAgUiRuntimeOptions = {
47
47
  adapters?: UseAgUiRuntimeAdapters;
48
48
  };
49
49
 
50
+ export type AgUiInterruptReason =
51
+ | "tool_call"
52
+ | "input_required"
53
+ | "confirmation"
54
+ | (string & {});
55
+
56
+ export type AgUiInterrupt = {
57
+ id: string;
58
+ reason: AgUiInterruptReason;
59
+ message?: string;
60
+ toolCallId?: string;
61
+ responseSchema?: Record<string, unknown>;
62
+ expiresAt?: string;
63
+ metadata?: Record<string, unknown>;
64
+ };
65
+
66
+ export type AgUiResumeEntry = {
67
+ interruptId: string;
68
+ status: "resolved" | "cancelled";
69
+ payload?: unknown;
70
+ };
71
+
72
+ export type AgUiRunFinishedOutcome =
73
+ | { type: "success" }
74
+ | { type: "interrupt"; interrupts: AgUiInterrupt[] };
75
+
50
76
  export type AgUiEvent =
51
77
  | { type: "RUN_STARTED"; runId: string }
52
- | { type: "RUN_FINISHED"; runId: string }
78
+ | {
79
+ type: "RUN_FINISHED";
80
+ runId: string;
81
+ outcome?: AgUiRunFinishedOutcome;
82
+ }
53
83
  | { type: "RUN_CANCELLED"; runId?: string }
54
84
  | { type: "RUN_ERROR"; message?: string; code?: string }
55
85
  | { type: "TEXT_MESSAGE_START"; messageId?: string }