@assistant-ui/react-ag-ui 0.0.57 → 0.0.58

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 (40) hide show
  1. package/README.md +16 -0
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +3 -4
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadRuntimeCore.js +29 -43
  5. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  7. package/dist/runtime/adapter/conversions.js +34 -11
  8. package/dist/runtime/adapter/conversions.js.map +1 -1
  9. package/dist/runtime/adapter/run-aggregator.d.ts +13 -3
  10. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.js +326 -119
  12. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  13. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  14. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  15. package/dist/runtime/adapter/subscriber.js +4 -1
  16. package/dist/runtime/adapter/subscriber.js.map +1 -1
  17. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.js +11 -12
  20. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  21. package/dist/runtime/event-parser.d.ts.map +1 -1
  22. package/dist/runtime/event-parser.js +176 -45
  23. package/dist/runtime/event-parser.js.map +1 -1
  24. package/dist/runtime/types.d.ts +42 -1
  25. package/dist/runtime/types.d.ts.map +1 -1
  26. package/dist/useAgUiRuntime.js +1 -0
  27. package/dist/useAgUiRuntime.js.map +1 -1
  28. package/package.json +9 -9
  29. package/src/runtime/AgUiThreadRuntimeCore.ts +40 -60
  30. package/src/runtime/adapter/conversions.ts +87 -17
  31. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  32. package/src/runtime/adapter/run-aggregator.ts +510 -139
  33. package/src/runtime/adapter/subscriber.ts +10 -1
  34. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  35. package/src/runtime/adapter/tool-approval.ts +17 -12
  36. package/src/runtime/event-parser.ts +216 -36
  37. package/src/runtime/types.ts +70 -12
  38. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  39. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  40. package/src/useAgUiRuntime.ts +1 -0
@@ -1 +1 @@
1
- {"version":3,"file":"useAgUiRuntime.js","names":[],"sources":["../src/useAgUiRuntime.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\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 { createMessageQueue } from \"@assistant-ui/core\";\nimport type {\n MessageQueueController,\n ToolExecutionStatus,\n} from \"@assistant-ui/core\";\nimport type {\n AssistantRuntime,\n AppendMessage,\n ExternalStoreAdapter,\n ThreadMessage,\n} from \"@assistant-ui/core\";\nimport type { ReadonlyJSONValue } from \"assistant-stream/utils\";\nimport { makeLogger } from \"./runtime/logger\";\nimport type {\n AgUiInterrupt,\n AgUiResumeEntry,\n UseAgUiRuntimeOptions,\n} from \"./runtime/types\";\nimport { AgUiThreadRuntimeCore } from \"./runtime/AgUiThreadRuntimeCore\";\nimport { agUiExtras } from \"./agUiExtras\";\nimport type { QueueItemState } from \"@assistant-ui/core/store\";\n\nconst EMPTY_INTERRUPTS: readonly AgUiInterrupt[] = [];\nconst EMPTY_QUEUE_ITEMS: readonly QueueItemState[] = Object.freeze([]);\nconst subscribeNoop = () => () => {};\n\nexport type AgUiAssistantRuntime = AssistantRuntime & {\n /**\n * @deprecated Use the `useAgUiInterrupts()` hook instead. This method is kept\n * for backward compatibility and will be removed in a future major release.\n */\n unstable_getPendingInterrupts: () => readonly AgUiInterrupt[];\n /**\n * @deprecated Use the `useAgUiSubmitInterruptResponses()` hook instead. This\n * method is kept for backward compatibility and will be removed in a future\n * major release.\n */\n unstable_submitInterruptResponses: (\n responses: readonly AgUiResumeEntry[],\n ) => Promise<void>;\n};\n\nexport function useAgUiRuntime(\n options: UseAgUiRuntimeOptions,\n): AgUiAssistantRuntime {\n const logger = useMemo(() => makeLogger(options.logger), [options.logger]);\n const [_version, setVersion] = useState(0);\n const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);\n const coreRef = useRef<AgUiThreadRuntimeCore | null>(null);\n const runtimeAdapters = useRuntimeAdapters();\n\n const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;\n const threadListAdapter = options.adapters?.threadList;\n\n if (!coreRef.current) {\n coreRef.current = new AgUiThreadRuntimeCore({\n agent: options.agent,\n logger,\n showThinking: options.showThinking ?? true,\n autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(historyAdapter && { history: historyAdapter }),\n notifyUpdate,\n });\n }\n\n const core = coreRef.current;\n useEffect(() => {\n core.updateOptions({\n agent: options.agent,\n logger,\n showThinking: options.showThinking ?? true,\n autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(historyAdapter && { history: historyAdapter }),\n });\n });\n\n const [toolStatuses, setToolStatuses] = useState<\n Record<string, ToolExecutionStatus>\n >({});\n\n const hasExecutingTools = Object.values(toolStatuses).some(\n (s) => s?.type === \"executing\",\n );\n const isRunning = core.isRunning() || hasExecutingTools;\n\n // The driver sends through the agent core rather than the runtime, because\n // the runtime's append routes every tail append back into this queue.\n const queueRef = useRef<MessageQueueController | null>(null);\n // Counts rendered busy edges, whether a run start or an interrupt arriving.\n // A dispatch whose count never moves was never observed as busy, so no\n // falling edge is coming to release the queue.\n const busyEdgesRef = useRef(0);\n if (options.unstable_enableMessageQueue && !queueRef.current) {\n queueRef.current = createMessageQueue({\n run: (message) => {\n const controller = queueRef.current;\n const edgesAtDispatch = busyEdgesRef.current;\n // The queue drops the item before dispatching and stays busy until an\n // idle edge. An append observed as busy is released by that falling\n // edge, and releasing again here would advance the queue twice; one\n // that never runs (a pre-run rejection, or startRun false) has to be\n // released here or every later send buffers forever. An interrupt is\n // rechecked because a run that resolves without yielding to React\n // reaches here before the effect below has seen either edge.\n const releaseIfNoRun = () => {\n if (\n queueRef.current !== controller ||\n busyEdgesRef.current !== edgesAtDispatch ||\n core.getPendingInterrupts() !== null\n )\n return;\n controller?.notifyIdle();\n };\n void core.append(message).then(releaseIfNoRun, (e: unknown) => {\n releaseIfNoRun();\n logger.error?.(\"[agui] queued message failed to send\", e);\n });\n },\n });\n } else if (!options.unstable_enableMessageQueue && queueRef.current) {\n queueRef.current.clear();\n queueRef.current = null;\n }\n const queueController = options.unstable_enableMessageQueue\n ? queueRef.current\n : null;\n\n // Feeds the store memo below: the runtime core skips an adapter whose\n // identity is unchanged, so queue items have to move the store reference or\n // subscribers never see composer.queue change.\n const queueItems = useSyncExternalStore(\n queueController?.subscribe ?? subscribeNoop,\n () => queueController?.adapter.items ?? EMPTY_QUEUE_ITEMS,\n () => EMPTY_QUEUE_ITEMS,\n );\n const steerQueueItems = useSyncExternalStore(\n queueController?.subscribe ?? subscribeNoop,\n () => queueController?.adapter.steerItems ?? EMPTY_QUEUE_ITEMS,\n () => EMPTY_QUEUE_ITEMS,\n );\n\n // Holds the queue while client-side tools are still executing, and while an\n // interrupt is unanswered: `append` refuses a new run until it is resolved,\n // and the queue drops an item before dispatching it, so draining into that\n // refusal would lose the message instead of keeping it visible.\n const queueBusy = isRunning || core.getPendingInterrupts() !== null;\n useEffect(() => {\n if (queueBusy) {\n busyEdgesRef.current++;\n queueController?.notifyBusy();\n } else {\n queueController?.notifyIdle();\n }\n }, [queueBusy, queueController]);\n\n const threadList = useMemo(() => {\n if (!threadListAdapter) return undefined;\n\n const { onSwitchToNewThread, onSwitchToThread, ...rest } =\n threadListAdapter;\n\n return {\n ...rest,\n onSwitchToNewThread: onSwitchToNewThread\n ? async () => {\n await onSwitchToNewThread();\n core.applyExternalMessages([]);\n core.resetState();\n }\n : undefined,\n onSwitchToThread: onSwitchToThread\n ? async (threadId: string) => {\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 const result = await onSwitchToThread(threadId);\n core.applyExternalMessages(result.messages);\n if (result.state !== undefined) {\n core.loadExternalState(result.state);\n } else {\n core.resetState();\n }\n if (result.unstable_resume) {\n void core.resumeInFlightRun(result.messages);\n }\n }\n : undefined,\n };\n }, [threadListAdapter, core]);\n\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 const shared = useExternalStoreSharedOptions(options);\n const store = useMemo(\n () => {\n void _version; // rerender on version change\n\n return {\n ...shared,\n isLoading: core.isLoading,\n messageRepository: core.getMessageRepository(),\n state: core.getState(),\n isRunning: core.isRunning(),\n extras: agUiExtras.provide({\n interrupts:\n core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,\n sendA2uiAction: (action) => core.sendA2uiAction(action),\n submitInterruptResponses: (responses) =>\n core.submitInterruptResponses(responses),\n steerAway: (message, responses) => core.steerAway(message, responses),\n state: core.getState(),\n setState: (next) => core.setState(next),\n }),\n unstable_enableToolInvocations: true,\n setToolStatuses,\n onNew: (message: AppendMessage) => core.append(message),\n onEdit: (message: AppendMessage) => {\n queueController?.clear();\n return core.edit(message);\n },\n onReload: (parentId: string | null, config: { runConfig?: any }) => {\n queueController?.clear();\n return core.reload(parentId, config);\n },\n onCancel: async () => {\n queueController?.notifyCancelled();\n core.cancel();\n },\n onAddToolResult: (options) => core.addToolResult(options),\n onRespondToToolApproval: (options) =>\n core.respondToToolApproval(options).catch((error: unknown) => {\n core.reportError(error);\n }),\n onResume: (config) => core.resume(config),\n setMessages: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onImport: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onLoadExternalState: (state: ReadonlyJSONValue) =>\n core.loadExternalState(state),\n adapters: adapterAdapters,\n ...(queueController && { queue: queueController.adapter }),\n } satisfies ExternalStoreAdapter<ThreadMessage>;\n },\n // _version is intentionally included to trigger re-computation when core state changes via notifyUpdate\n // toolInvocations intentionally excluded: abort/resume use refs internally and work with stale captures\n [\n adapterAdapters,\n core,\n _version,\n isRunning,\n queueController,\n queueItems,\n steerQueueItems,\n shared,\n ],\n );\n\n const baseRuntime = useExternalStoreRuntime(store);\n\n const runtime = useMemo<AgUiAssistantRuntime>(() => {\n const wrapper = Object.create(baseRuntime) as AgUiAssistantRuntime;\n wrapper.unstable_getPendingInterrupts = () =>\n core.getPendingInterrupts()?.interrupts ?? [];\n wrapper.unstable_submitInterruptResponses = (responses) =>\n core.submitInterruptResponses(responses);\n return wrapper;\n }, [baseRuntime, core]);\n\n useEffect(() => {\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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"}
1
+ {"version":3,"file":"useAgUiRuntime.js","names":[],"sources":["../src/useAgUiRuntime.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\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 { createMessageQueue } from \"@assistant-ui/core\";\nimport type {\n MessageQueueController,\n ToolExecutionStatus,\n} from \"@assistant-ui/core\";\nimport type {\n AssistantRuntime,\n AppendMessage,\n ExternalStoreAdapter,\n ThreadMessage,\n} from \"@assistant-ui/core\";\nimport type { ReadonlyJSONValue } from \"assistant-stream/utils\";\nimport { makeLogger } from \"./runtime/logger\";\nimport type {\n AgUiInterrupt,\n AgUiResumeEntry,\n UseAgUiRuntimeOptions,\n} from \"./runtime/types\";\nimport { AgUiThreadRuntimeCore } from \"./runtime/AgUiThreadRuntimeCore\";\nimport { agUiExtras } from \"./agUiExtras\";\nimport type { QueueItemState } from \"@assistant-ui/core/store\";\n\nconst EMPTY_INTERRUPTS: readonly AgUiInterrupt[] = [];\nconst EMPTY_QUEUE_ITEMS: readonly QueueItemState[] = Object.freeze([]);\nconst subscribeNoop = () => () => {};\n\nexport type AgUiAssistantRuntime = AssistantRuntime & {\n /**\n * @deprecated Use the `useAgUiInterrupts()` hook instead. This method is kept\n * for backward compatibility and will be removed in a future major release.\n */\n unstable_getPendingInterrupts: () => readonly AgUiInterrupt[];\n /**\n * @deprecated Use the `useAgUiSubmitInterruptResponses()` hook instead. This\n * method is kept for backward compatibility and will be removed in a future\n * major release.\n */\n unstable_submitInterruptResponses: (\n responses: readonly AgUiResumeEntry[],\n ) => Promise<void>;\n};\n\nexport function useAgUiRuntime(\n options: UseAgUiRuntimeOptions,\n): AgUiAssistantRuntime {\n const logger = useMemo(() => makeLogger(options.logger), [options.logger]);\n const [_version, setVersion] = useState(0);\n const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);\n const coreRef = useRef<AgUiThreadRuntimeCore | null>(null);\n const runtimeAdapters = useRuntimeAdapters();\n\n const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;\n const threadListAdapter = options.adapters?.threadList;\n\n if (!coreRef.current) {\n coreRef.current = new AgUiThreadRuntimeCore({\n agent: options.agent,\n logger,\n showThinking: options.showThinking ?? true,\n autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(historyAdapter && { history: historyAdapter }),\n notifyUpdate,\n });\n }\n\n const core = coreRef.current;\n useEffect(() => {\n core.updateOptions({\n agent: options.agent,\n logger,\n showThinking: options.showThinking ?? true,\n autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(historyAdapter && { history: historyAdapter }),\n });\n });\n\n const [toolStatuses, setToolStatuses] = useState<\n Record<string, ToolExecutionStatus>\n >({});\n\n const hasExecutingTools = Object.values(toolStatuses).some(\n (s) => s?.type === \"executing\",\n );\n const isRunning = core.isRunning() || hasExecutingTools;\n\n // The driver sends through the agent core rather than the runtime, because\n // the runtime's append routes every tail append back into this queue.\n const queueRef = useRef<MessageQueueController | null>(null);\n // Counts rendered busy edges, whether a run start or an interrupt arriving.\n // A dispatch whose count never moves was never observed as busy, so no\n // falling edge is coming to release the queue.\n const busyEdgesRef = useRef(0);\n if (options.unstable_enableMessageQueue && !queueRef.current) {\n queueRef.current = createMessageQueue({\n run: (message) => {\n const controller = queueRef.current;\n const edgesAtDispatch = busyEdgesRef.current;\n // The queue drops the item before dispatching and stays busy until an\n // idle edge. An append observed as busy is released by that falling\n // edge, and releasing again here would advance the queue twice; one\n // that never runs (a pre-run rejection, or startRun false) has to be\n // released here or every later send buffers forever. An interrupt is\n // rechecked because a run that resolves without yielding to React\n // reaches here before the effect below has seen either edge.\n const releaseIfNoRun = () => {\n if (\n queueRef.current !== controller ||\n busyEdgesRef.current !== edgesAtDispatch ||\n core.getPendingInterrupts() !== null\n )\n return;\n controller?.notifyIdle();\n };\n void core.append(message).then(releaseIfNoRun, (e: unknown) => {\n releaseIfNoRun();\n logger.error?.(\"[agui] queued message failed to send\", e);\n });\n },\n });\n } else if (!options.unstable_enableMessageQueue && queueRef.current) {\n queueRef.current.clear();\n queueRef.current = null;\n }\n const queueController = options.unstable_enableMessageQueue\n ? queueRef.current\n : null;\n\n // Feeds the store memo below: the runtime core skips an adapter whose\n // identity is unchanged, so queue items have to move the store reference or\n // subscribers never see composer.queue change.\n const queueItems = useSyncExternalStore(\n queueController?.subscribe ?? subscribeNoop,\n () => queueController?.adapter.items ?? EMPTY_QUEUE_ITEMS,\n () => EMPTY_QUEUE_ITEMS,\n );\n const steerQueueItems = useSyncExternalStore(\n queueController?.subscribe ?? subscribeNoop,\n () => queueController?.adapter.steerItems ?? EMPTY_QUEUE_ITEMS,\n () => EMPTY_QUEUE_ITEMS,\n );\n\n // Holds the queue while client-side tools are still executing, and while an\n // interrupt is unanswered: `append` refuses a new run until it is resolved,\n // and the queue drops an item before dispatching it, so draining into that\n // refusal would lose the message instead of keeping it visible.\n const queueBusy = isRunning || core.getPendingInterrupts() !== null;\n useEffect(() => {\n if (queueBusy) {\n busyEdgesRef.current++;\n queueController?.notifyBusy();\n } else {\n queueController?.notifyIdle();\n }\n }, [queueBusy, queueController]);\n\n const threadList = useMemo(() => {\n if (!threadListAdapter) return undefined;\n\n const { onSwitchToNewThread, onSwitchToThread, ...rest } =\n threadListAdapter;\n\n return {\n ...rest,\n onSwitchToNewThread: onSwitchToNewThread\n ? async () => {\n await onSwitchToNewThread();\n core.applyExternalMessages([]);\n core.resetState();\n }\n : undefined,\n onSwitchToThread: onSwitchToThread\n ? async (threadId: string) => {\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 const result = await onSwitchToThread(threadId);\n core.applyExternalMessages(result.messages);\n if (result.state !== undefined) {\n core.loadExternalState(result.state);\n } else {\n core.resetState();\n }\n if (result.unstable_resume) {\n void core.resumeInFlightRun(result.messages);\n }\n }\n : undefined,\n };\n }, [threadListAdapter, core]);\n\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 const shared = useExternalStoreSharedOptions(options);\n const store = useMemo(\n () => {\n void _version; // rerender on version change\n\n return {\n ...shared,\n isLoading: core.isLoading,\n messageRepository: core.getMessageRepository(),\n state: core.getState(),\n isRunning: core.isRunning(),\n extras: agUiExtras.provide({\n interrupts:\n core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,\n sendA2uiAction: (action) => core.sendA2uiAction(action),\n submitInterruptResponses: (responses) =>\n core.submitInterruptResponses(responses),\n steerAway: (message, responses) => core.steerAway(message, responses),\n state: core.getState(),\n setState: (next) => core.setState(next),\n }),\n unstable_enableToolInvocations: true,\n setToolStatuses,\n onNew: (message: AppendMessage) => core.append(message),\n onEdit: (message: AppendMessage) => {\n queueController?.clear();\n return core.edit(message);\n },\n onReload: (parentId: string | null, config: { runConfig?: any }) => {\n queueController?.clear();\n return core.reload(parentId, config);\n },\n onCancel: async () => {\n queueController?.notifyCancelled();\n core.cancel();\n },\n onAddToolResult: (options) => core.addToolResult(options),\n onRespondToToolApproval: (options) =>\n core.respondToToolApproval(options).catch((error: unknown) => {\n core.reportError(error);\n throw error;\n }),\n onResume: (config) => core.resume(config),\n setMessages: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onImport: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onLoadExternalState: (state: ReadonlyJSONValue) =>\n core.loadExternalState(state),\n adapters: adapterAdapters,\n ...(queueController && { queue: queueController.adapter }),\n } satisfies ExternalStoreAdapter<ThreadMessage>;\n },\n // _version is intentionally included to trigger re-computation when core state changes via notifyUpdate\n // toolInvocations intentionally excluded: abort/resume use refs internally and work with stale captures\n [\n adapterAdapters,\n core,\n _version,\n isRunning,\n queueController,\n queueItems,\n steerQueueItems,\n shared,\n ],\n );\n\n const baseRuntime = useExternalStoreRuntime(store);\n\n const runtime = useMemo<AgUiAssistantRuntime>(() => {\n const wrapper = Object.create(baseRuntime) as AgUiAssistantRuntime;\n wrapper.unstable_getPendingInterrupts = () =>\n core.getPendingInterrupts()?.interrupts ?? [];\n wrapper.unstable_submitInterruptResponses = (responses) =>\n core.submitInterruptResponses(responses);\n return wrapper;\n }, [baseRuntime, core]);\n\n useEffect(() => {\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-ag-ui",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
4
4
  "description": "AG-UI protocol adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "ag-ui",
@@ -29,11 +29,11 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@ag-ui/client": "^0.0.58",
33
- "@assistant-ui/core": "^0.3.16",
34
- "@assistant-ui/react-generative-ui": "^0.0.16",
35
- "@assistant-ui/store": "^0.3.11",
36
- "assistant-stream": "^0.3.40",
32
+ "@ag-ui/client": "^0.0.59",
33
+ "@assistant-ui/core": "^0.3.17",
34
+ "@assistant-ui/react-generative-ui": "^0.0.17",
35
+ "@assistant-ui/store": "^0.3.12",
36
+ "assistant-stream": "^0.3.41",
37
37
  "fast-json-patch": "^3.1.1"
38
38
  },
39
39
  "peerDependencies": {
@@ -46,14 +46,14 @@
46
46
  }
47
47
  },
48
48
  "devDependencies": {
49
- "@assistant-ui/x-buildutils": "0.0.24",
49
+ "@assistant-ui/x-buildutils": "0.0.25",
50
50
  "@testing-library/dom": "^10.4.1",
51
- "@testing-library/react": "^16.3.2",
51
+ "@testing-library/react": "^16.3.3",
52
52
  "@types/react": "^19.2.18",
53
53
  "jsdom": "^30.0.1",
54
54
  "react": "^19.2.8",
55
55
  "vitest": "^4.1.11",
56
- "zod": "^4.4.3"
56
+ "zod": "^4.5.4"
57
57
  },
58
58
  "publishConfig": {
59
59
  "access": "public",
@@ -23,6 +23,8 @@ import type {
23
23
  import {
24
24
  createMessageRepositorySession,
25
25
  invokeUserCallback,
26
+ iterateToolCallParts,
27
+ mapToolCallPartsDeep,
26
28
  } from "@assistant-ui/core/internal";
27
29
  import type {
28
30
  AbstractAgent,
@@ -74,8 +76,6 @@ const isResolvedToolCall = (
74
76
  ): boolean =>
75
77
  part.type === "tool-call" && "result" in part && part.result !== undefined;
76
78
 
77
- const symbolResumeShim = Symbol("agui-resume-shim");
78
-
79
79
  type RunConfig = NonNullable<AppendMessage["runConfig"]>;
80
80
  type ResumeStream = (
81
81
  options: ChatModelRunOptions,
@@ -187,7 +187,6 @@ export class AgUiThreadRuntimeCore {
187
187
  this.onCancel = options.onCancel;
188
188
  this.history = options.history;
189
189
  this.notifyUpdate = options.notifyUpdate;
190
- this.installResumeShim();
191
190
  }
192
191
 
193
192
  updateOptions(options: Omit<CoreOptions, "notifyUpdate">) {
@@ -199,8 +198,6 @@ export class AgUiThreadRuntimeCore {
199
198
  this.onCancel = options.onCancel;
200
199
  const previousHistory = this.history;
201
200
  this.history = options.history;
202
- this.installResumeShim();
203
-
204
201
  if (
205
202
  this._loadRequested &&
206
203
  !this._loadPromise &&
@@ -439,8 +436,7 @@ export class AgUiThreadRuntimeCore {
439
436
  const assistant = this.findRequiresActionAssistant("tool-calls");
440
437
  if (!assistant) return null;
441
438
  const toolCallIds: string[] = [];
442
- for (const part of assistant.content) {
443
- if (part.type !== "tool-call") continue;
439
+ for (const part of iterateToolCallParts(assistant.content)) {
444
440
  if (isResolvedToolCall(part)) continue;
445
441
  toolCallIds.push(part.toolCallId);
446
442
  }
@@ -498,9 +494,9 @@ export class AgUiThreadRuntimeCore {
498
494
  }
499
495
 
500
496
  /**
501
- * The core seams that reach `respondToToolApproval` discard the promise they
502
- * receive, so a rejected decision would otherwise surface only as an
503
- * unhandled rejection. A failure raised by the resumed run itself was already
497
+ * A rejected decision reaches the caller through the approval seam, and
498
+ * `onError` in addition, because a consumer watching only the runtime would
499
+ * otherwise miss it. A failure raised by the resumed run itself was already
504
500
  * reported by `startRun` before it rethrew, so reporting it here again would
505
501
  * give the consumer two notifications for one failure.
506
502
  */
@@ -557,9 +553,9 @@ export class AgUiThreadRuntimeCore {
557
553
  const gated = projectAgUiToolApprovals(
558
554
  pending.interrupts,
559
555
  new Set(
560
- (gatedMessage?.content ?? [])
561
- .filter((part) => part.type === "tool-call")
562
- .map((part) => part.toolCallId),
556
+ Array.from(iterateToolCallParts(gatedMessage?.content ?? [])).map(
557
+ (part) => part.toolCallId,
558
+ ),
563
559
  ),
564
560
  );
565
561
  const isGated = [...gated.values()].some(
@@ -774,9 +770,8 @@ export class AgUiThreadRuntimeCore {
774
770
  for (let index = messages.length - 1; index >= 0; index--) {
775
771
  const message = messages[index];
776
772
  if (!message || message.role !== "assistant") continue;
777
- for (const part of message.content) {
778
- if (part.type !== "tool-call" || part.toolCallId !== toolCallId)
779
- continue;
773
+ for (const part of iterateToolCallParts(message.content)) {
774
+ if (part.toolCallId !== toolCallId) continue;
780
775
  if (!isResolvedToolCall(part)) {
781
776
  return message.id;
782
777
  }
@@ -790,8 +785,8 @@ export class AgUiThreadRuntimeCore {
790
785
  const updated = this.session.updateMessage(messageId, (message) => {
791
786
  if (message.role !== "assistant") return message;
792
787
  const assistant = message as ThreadAssistantMessage;
793
- const content = assistant.content.map((part) => {
794
- if (part.type !== "tool-call" || isResolvedToolCall(part)) return part;
788
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
789
+ if (isResolvedToolCall(part)) return part;
795
790
  return {
796
791
  ...part,
797
792
  result: { error: "Tool call cancelled by user" },
@@ -804,13 +799,19 @@ export class AgUiThreadRuntimeCore {
804
799
  }
805
800
 
806
801
  addToolResult(options: AddToolResultOptions): void {
807
- const updated = this.session.updateMessage(options.messageId, (message) => {
802
+ // Core's ToolInvocationTracker resolves a nested call to the nested
803
+ // subagent message's id, which is not a session message; re-anchor on the
804
+ // top-level message that owns the tree so the update can land.
805
+ const sessionMessageId = this.session.tryGetMessage(options.messageId)
806
+ ? options.messageId
807
+ : this.findMessageIdForToolCall(options.toolCallId);
808
+ if (sessionMessageId === undefined) return;
809
+ const updated = this.session.updateMessage(sessionMessageId, (message) => {
808
810
  if (message.role !== "assistant") return message;
809
811
  const assistant = message as ThreadAssistantMessage;
810
812
  let matchedToolCall = false;
811
- const content = assistant.content.map((part) => {
812
- if (part.type !== "tool-call" || part.toolCallId !== options.toolCallId)
813
- return part;
813
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
814
+ if (part.toolCallId !== options.toolCallId) return part;
814
815
  matchedToolCall = true;
815
816
  return {
816
817
  ...part,
@@ -825,7 +826,7 @@ export class AgUiThreadRuntimeCore {
825
826
 
826
827
  if (!updated) return;
827
828
  this.notifyUpdate();
828
- this.maybeResumeAfterToolResults(options.messageId);
829
+ this.maybeResumeAfterToolResults(sessionMessageId);
829
830
  }
830
831
 
831
832
  sendA2uiAction(action: Record<string, unknown>): void {
@@ -878,9 +879,13 @@ export class AgUiThreadRuntimeCore {
878
879
  ) {
879
880
  return false;
880
881
  }
881
- const allResolved = assistant.content.every(
882
- (part) => part.type !== "tool-call" || isResolvedToolCall(part),
883
- );
882
+ let allResolved = true;
883
+ for (const part of iterateToolCallParts(assistant.content)) {
884
+ if (!isResolvedToolCall(part)) {
885
+ allResolved = false;
886
+ break;
887
+ }
888
+ }
884
889
  if (!allResolved) return false;
885
890
 
886
891
  const updated = this.session.updateMessage(messageId, (current) =>
@@ -1319,27 +1324,6 @@ export class AgUiThreadRuntimeCore {
1319
1324
  return input;
1320
1325
  }
1321
1326
 
1322
- private installResumeShim(): void {
1323
- const agent = this.agent as any;
1324
- if (agent[symbolResumeShim]) return;
1325
- agent[symbolResumeShim] = true;
1326
- const onInstance = Object.hasOwn(agent, "prepareRunAgentInput");
1327
- const original = onInstance
1328
- ? agent.prepareRunAgentInput
1329
- : Object.getPrototypeOf(agent)?.prepareRunAgentInput;
1330
- if (typeof original !== "function") return;
1331
- agent.prepareRunAgentInput = function (
1332
- this: unknown,
1333
- params: { resume?: unknown } | undefined,
1334
- ) {
1335
- const input = original.call(this, params);
1336
- if (params?.resume !== undefined && input && typeof input === "object") {
1337
- return { ...(input as object), resume: params.resume };
1338
- }
1339
- return input;
1340
- };
1341
- }
1342
-
1343
1327
  private setRunning(running: boolean) {
1344
1328
  this.isRunningFlag = running;
1345
1329
  this.notifyUpdate();
@@ -1498,19 +1482,17 @@ export class AgUiThreadRuntimeCore {
1498
1482
  next: ThreadAssistantMessage["content"],
1499
1483
  ): ThreadAssistantMessage["content"] {
1500
1484
  const resolved = new Map<string, ToolCallMessagePart>();
1501
- for (const part of previous) {
1502
- if (part.type === "tool-call" && isResolvedToolCall(part)) {
1485
+ for (const part of iterateToolCallParts(previous)) {
1486
+ if (isResolvedToolCall(part)) {
1503
1487
  resolved.set(part.toolCallId, part);
1504
1488
  }
1505
1489
  }
1506
1490
  if (resolved.size === 0) return next;
1507
1491
 
1508
- let changed = false;
1509
- const merged = next.map((part) => {
1510
- if (part.type !== "tool-call" || isResolvedToolCall(part)) return part;
1492
+ const { content: merged, changed } = mapToolCallPartsDeep(next, (part) => {
1493
+ if (isResolvedToolCall(part)) return part;
1511
1494
  const prior = resolved.get(part.toolCallId);
1512
1495
  if (!prior) return part;
1513
- changed = true;
1514
1496
  return {
1515
1497
  ...part,
1516
1498
  result: prior.result,
@@ -1518,7 +1500,7 @@ export class AgUiThreadRuntimeCore {
1518
1500
  ...(prior.isError !== undefined ? { isError: prior.isError } : {}),
1519
1501
  };
1520
1502
  });
1521
- return changed ? (merged as ThreadAssistantMessage["content"]) : next;
1503
+ return changed ? merged : next;
1522
1504
  }
1523
1505
 
1524
1506
  private mergeAssistantMetadata(
@@ -1623,9 +1605,8 @@ export class AgUiThreadRuntimeCore {
1623
1605
  const assistant = message as ThreadAssistantMessage;
1624
1606
  const mcpAppUri = readMcpAppResourceUri(event.mcpResult?._meta);
1625
1607
  let matchedToolCall = false;
1626
- const content = assistant.content.map((part) => {
1627
- if (part.type !== "tool-call" || part.toolCallId !== event.toolCallId)
1628
- return part;
1608
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
1609
+ if (part.toolCallId !== event.toolCallId) return part;
1629
1610
  matchedToolCall = true;
1630
1611
  // An applied activity snapshot owns part.result; a later result only
1631
1612
  // fills what is missing, mirroring the aggregator's finishToolCall.
@@ -1709,9 +1690,8 @@ export class AgUiThreadRuntimeCore {
1709
1690
  if (message.role !== "assistant") return message;
1710
1691
  const assistant = message as ThreadAssistantMessage;
1711
1692
  let matchedToolCall = false;
1712
- const content = assistant.content.map((part) => {
1713
- if (part.type !== "tool-call" || part.toolCallId !== toolCallId)
1714
- return part;
1693
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
1694
+ if (part.toolCallId !== toolCallId) return part;
1715
1695
  matchedToolCall = true;
1716
1696
  return {
1717
1697
  ...part,
@@ -5,6 +5,7 @@ import { generateId } from "@assistant-ui/core";
5
5
  import type {
6
6
  ThreadMessageLike as CoreThreadMessageLike,
7
7
  PartProviderMetadata,
8
+ ThreadMessage,
8
9
  ToolCallMessagePartMcpMetadata,
9
10
  ToolModelContentPart,
10
11
  } from "@assistant-ui/core";
@@ -106,8 +107,17 @@ type ToolCallPart = {
106
107
  modelContent?: readonly ToolModelContentPart[];
107
108
  unstable_toolMessageId?: string;
108
109
  mcp?: ToolCallMessagePartMcpMetadata;
110
+ messages?: readonly ThreadMessage[];
111
+ approval?: CoreToolCallPartApproval;
109
112
  };
110
113
 
114
+ type CoreToolCallPartApproval = NonNullable<
115
+ Extract<
116
+ Exclude<CoreThreadMessageLike["content"], string>[number],
117
+ { type: "tool-call" }
118
+ >["approval"]
119
+ >;
120
+
111
121
  const isObject = (value: unknown): value is Record<string, unknown> =>
112
122
  typeof value === "object" && value !== null;
113
123
 
@@ -1051,36 +1061,96 @@ function convertAssistantMessage(
1051
1061
  return;
1052
1062
  }
1053
1063
 
1064
+ // A subagent's tool calls live on nested assistant messages. Before
1065
+ // subagent attribution they flattened to root and went out with this
1066
+ // assistant record as their antecedent, so the resume payload restores
1067
+ // exactly that shape: the calls join this record's toolCalls and their
1068
+ // results follow as tool records — never a tool record without its call.
1069
+ // The nested assistant content itself is backend-owned state and is not
1070
+ // re-sent.
1071
+ const nestedToolCalls: {
1072
+ id: string;
1073
+ call: AgUiToolCall;
1074
+ part: ToolCallPart;
1075
+ }[] = [];
1076
+ for (const { part } of toolCalls) {
1077
+ collectNestedToolCalls(part, nestedToolCalls);
1078
+ }
1079
+
1054
1080
  converted.push({
1055
1081
  id: message.id,
1056
1082
  role: "assistant",
1057
1083
  content,
1058
1084
  ...(message.name ? { name: message.name } : {}),
1059
- ...(toolCalls.length > 0
1060
- ? { toolCalls: toolCalls.map((entry) => entry.call) }
1085
+ ...(toolCalls.length + nestedToolCalls.length > 0
1086
+ ? {
1087
+ toolCalls: [...toolCalls, ...nestedToolCalls].map(
1088
+ (entry) => entry.call,
1089
+ ),
1090
+ }
1061
1091
  : {}),
1062
1092
  });
1063
1093
 
1064
1094
  for (const { id: toolCallId, part } of toolCalls) {
1065
- if (part.result === undefined) continue;
1066
-
1067
- const resultContent =
1068
- part.modelContent !== undefined
1069
- ? extractText(part.modelContent)
1070
- : typeof part.result === "string"
1071
- ? part.result
1072
- : JSON.stringify(part.result);
1095
+ emitToolResult(toolCallId, part, converted);
1096
+ }
1097
+ for (const { id: toolCallId, part } of nestedToolCalls) {
1098
+ // A result recorded while the call's approval gate is still open must not
1099
+ // reach the backend as if the gate had been decided.
1100
+ const gateOpen =
1101
+ part.approval != null &&
1102
+ part.approval.approved === undefined &&
1103
+ part.approval.resolution === undefined;
1104
+ if (gateOpen) continue;
1105
+ emitToolResult(toolCallId, part, converted);
1106
+ }
1107
+ }
1073
1108
 
1074
- converted.push({
1075
- id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
1076
- role: "tool",
1077
- content: resultContent,
1078
- toolCallId,
1079
- ...(part.isError ? { error: resultContent } : {}),
1080
- });
1109
+ function collectNestedToolCalls(
1110
+ part: ToolCallPart,
1111
+ out: { id: string; call: AgUiToolCall; part: ToolCallPart }[],
1112
+ ): void {
1113
+ for (const nested of part.messages ?? []) {
1114
+ if (!isObject(nested) || nested.role !== "assistant") continue;
1115
+ const nestedContent = Array.isArray(nested.content) ? nested.content : [];
1116
+ for (const nestedPart of nestedContent) {
1117
+ if (!isObject(nestedPart) || nestedPart.type !== "tool-call") continue;
1118
+ const nestedToolCall = nestedPart as ToolCallPart;
1119
+ if (
1120
+ typeof nestedToolCall.toolCallId !== "string" ||
1121
+ nestedToolCall.toolCallId.startsWith("a2ui:")
1122
+ ) {
1123
+ continue;
1124
+ }
1125
+ out.push({ ...normalizeToolCall(nestedToolCall), part: nestedToolCall });
1126
+ collectNestedToolCalls(nestedToolCall, out);
1127
+ }
1081
1128
  }
1082
1129
  }
1083
1130
 
1131
+ function emitToolResult(
1132
+ toolCallId: string,
1133
+ part: ToolCallPart,
1134
+ converted: AgUiMessage[],
1135
+ ): void {
1136
+ if (part.result === undefined) return;
1137
+
1138
+ const resultContent =
1139
+ part.modelContent !== undefined
1140
+ ? extractText(part.modelContent)
1141
+ : typeof part.result === "string"
1142
+ ? part.result
1143
+ : JSON.stringify(part.result);
1144
+
1145
+ converted.push({
1146
+ id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
1147
+ role: "tool",
1148
+ content: resultContent,
1149
+ toolCallId,
1150
+ ...(part.isError ? { error: resultContent } : {}),
1151
+ });
1152
+ }
1153
+
1084
1154
  function convertToolMessage(
1085
1155
  message: NormalizedThreadMessageLike,
1086
1156
  converted: AgUiMessage[],
@@ -100,4 +100,132 @@ describe("RunAggregator tool approval projection", () => {
100
100
  ?.interrupts,
101
101
  ).toEqual([GATE]);
102
102
  });
103
+
104
+ /**
105
+ * `boundToolCallIds` names every call the run produced, root or nested, so a
106
+ * gate on a call inside a subagent message is bound and projects onto the
107
+ * nested part rather than collapsing the batch. The approval seams walk
108
+ * `ToolCallMessagePart.messages`, which is what makes a subagent-scoped gate
109
+ * answerable the same way a root one is.
110
+ */
111
+ it("projects a batch that names a subagent-scoped tool call onto both the root and nested parts", () => {
112
+ const emitted: ChatModelRunResult[] = [];
113
+ const aggregator = new RunAggregator({
114
+ showThinking: false,
115
+ logger: noopLogger,
116
+ emit: (result) => {
117
+ emitted.push(result);
118
+ },
119
+ });
120
+
121
+ const ROOT_GATE: AgUiInterrupt = {
122
+ id: "int-root",
123
+ reason: "tool_call",
124
+ toolCallId: "tc-root",
125
+ message: "Delete /tmp/root?",
126
+ };
127
+ const SUBAGENT_GATE: AgUiInterrupt = {
128
+ id: "int-sub",
129
+ reason: "tool_call",
130
+ toolCallId: "tc-sub",
131
+ message: "Delete /tmp/sub?",
132
+ };
133
+
134
+ aggregator.handle({ type: "RUN_STARTED", runId: "run-1" });
135
+ aggregator.handle({
136
+ type: "TOOL_CALL_START",
137
+ toolCallId: "tc-root",
138
+ toolCallName: "delete_file",
139
+ });
140
+ aggregator.handle({
141
+ type: "TOOL_CALL_ARGS",
142
+ toolCallId: "tc-root",
143
+ delta: '{"path":"/tmp/root"}',
144
+ });
145
+ aggregator.handle({ type: "TOOL_CALL_END", toolCallId: "tc-root" });
146
+ aggregator.handle({
147
+ type: "SUBAGENT_STARTED",
148
+ subagentRunId: "sub-1",
149
+ name: "cleanup",
150
+ parentToolCallId: "tc-root",
151
+ } as any);
152
+ aggregator.handle({
153
+ type: "TOOL_CALL_START",
154
+ toolCallId: "tc-sub",
155
+ toolCallName: "delete_file",
156
+ subagentRunId: "sub-1",
157
+ } as any);
158
+ aggregator.handle({
159
+ type: "TOOL_CALL_ARGS",
160
+ toolCallId: "tc-sub",
161
+ delta: '{"path":"/tmp/sub"}',
162
+ subagentRunId: "sub-1",
163
+ } as any);
164
+ aggregator.handle({
165
+ type: "TOOL_CALL_END",
166
+ toolCallId: "tc-sub",
167
+ subagentRunId: "sub-1",
168
+ } as any);
169
+ aggregator.handle({
170
+ type: "RUN_FINISHED",
171
+ runId: "run-1",
172
+ outcome: {
173
+ type: "interrupt",
174
+ interrupts: [ROOT_GATE, SUBAGENT_GATE],
175
+ },
176
+ } as any);
177
+
178
+ const result = emitted[emitted.length - 1]!;
179
+ expect(result.status).toMatchObject({
180
+ type: "requires-action",
181
+ reason: "interrupt",
182
+ });
183
+ const content = result.content ?? [];
184
+ const rootPart = content.find(
185
+ (p: any) => p.type === "tool-call" && p.toolCallId === "tc-root",
186
+ ) as any;
187
+ expect(rootPart?.approval).toEqual({
188
+ id: "int-root",
189
+ prompt: "Delete /tmp/root?",
190
+ });
191
+ const nestedPart = rootPart?.messages?.[0]?.content.find(
192
+ (p: any) => p.type === "tool-call" && p.toolCallId === "tc-sub",
193
+ );
194
+ expect(nestedPart?.approval).toEqual({
195
+ id: "int-sub",
196
+ prompt: "Delete /tmp/sub?",
197
+ });
198
+ // The interrupts survive on the message metadata so the app's bespoke
199
+ // interrupt hooks (useAgUiSubmitInterruptResponses / steerAway) can still
200
+ // resolve the batch.
201
+ expect(
202
+ (result.metadata?.custom as Record<string, any> | undefined)?.agui
203
+ ?.interrupts,
204
+ ).toEqual([ROOT_GATE, SUBAGENT_GATE]);
205
+ });
206
+
207
+ it("still projects a root gate whose batch is entirely root-scoped, even while an unrelated subagent tool call is in flight", () => {
208
+ const { aggregator, last } = gatedAggregator();
209
+
210
+ aggregator.handle({
211
+ type: "SUBAGENT_STARTED",
212
+ subagentRunId: "sub-1",
213
+ name: "cleanup",
214
+ } as any);
215
+ aggregator.handle({
216
+ type: "TOOL_CALL_START",
217
+ toolCallId: "tc-unrelated",
218
+ toolCallName: "noop",
219
+ subagentRunId: "sub-1",
220
+ } as any);
221
+
222
+ expect(last().status).toMatchObject({
223
+ type: "requires-action",
224
+ reason: "interrupt",
225
+ });
226
+ expect(approvalOf(last())).toEqual({
227
+ id: "int-1",
228
+ prompt: "Delete /tmp/a?",
229
+ });
230
+ });
103
231
  });