@assistant-ui/core 0.2.5 → 0.2.7

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 (140) hide show
  1. package/dist/index.d.ts +4 -2
  2. package/dist/index.js +6 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/internal/duplicate-detection.d.ts +5 -0
  5. package/dist/internal/duplicate-detection.d.ts.map +1 -0
  6. package/dist/internal/duplicate-detection.js +11 -0
  7. package/dist/internal/duplicate-detection.js.map +1 -0
  8. package/dist/react/AssistantProvider.d.ts.map +1 -1
  9. package/dist/react/AssistantProvider.js.map +1 -1
  10. package/dist/react/index.d.ts +3 -2
  11. package/dist/react/index.js +2 -2
  12. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  13. package/dist/react/primitives/message/MessageGroupedParts.d.ts +25 -21
  14. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  15. package/dist/react/primitives/message/MessageGroupedParts.js +6 -7
  16. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  17. package/dist/react/primitives/message/MessageParts.d.ts +2 -1
  18. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  19. package/dist/react/primitives/message/MessageParts.js +9 -4
  20. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  21. package/dist/react/providers/TextMessagePartProvider.d.ts.map +1 -1
  22. package/dist/react/providers/TextMessagePartProvider.js +3 -0
  23. package/dist/react/providers/TextMessagePartProvider.js.map +1 -1
  24. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +3 -1
  25. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  26. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +3 -1
  27. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  28. package/dist/react/runtimes/external-message-converter.d.ts +1 -1
  29. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  30. package/dist/react/runtimes/external-message-converter.js +7 -3
  31. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  32. package/dist/react/types/MessagePartComponentTypes.d.ts +8 -0
  33. package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
  34. package/dist/react/utils/groupParts.d.ts +40 -12
  35. package/dist/react/utils/groupParts.d.ts.map +1 -1
  36. package/dist/react/utils/groupParts.js +51 -9
  37. package/dist/react/utils/groupParts.js.map +1 -1
  38. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  39. package/dist/runtime/api/attachment-runtime.js.map +1 -1
  40. package/dist/runtime/api/message-part-runtime.d.ts +8 -0
  41. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  42. package/dist/runtime/api/message-part-runtime.js +13 -0
  43. package/dist/runtime/api/message-part-runtime.js.map +1 -1
  44. package/dist/runtime/api/thread-runtime.d.ts +2 -1
  45. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  46. package/dist/runtime/base/base-thread-runtime-core.d.ts +2 -1
  47. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  48. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  49. package/dist/runtime/interfaces/thread-runtime-core.d.ts +15 -1
  50. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  51. package/dist/runtime/utils/thread-message-like.d.ts +10 -0
  52. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  53. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  54. package/dist/runtimes/external-store/external-store-adapter.d.ts +33 -1
  55. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  56. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  57. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  58. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +27 -1
  59. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  60. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +98 -3
  61. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  62. package/dist/runtimes/local/local-thread-runtime-core.d.ts +2 -1
  63. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  64. package/dist/runtimes/local/local-thread-runtime-core.js +3 -0
  65. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  66. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
  67. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  68. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +3 -0
  69. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  70. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  71. package/dist/runtimes/remote-thread-list/empty-thread-core.js +3 -0
  72. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  73. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts +168 -0
  74. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -0
  75. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +449 -0
  76. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -0
  77. package/dist/store/clients/thread-message-client.d.ts.map +1 -1
  78. package/dist/store/clients/thread-message-client.js +3 -0
  79. package/dist/store/clients/thread-message-client.js.map +1 -1
  80. package/dist/store/runtime-clients/message-part-runtime-client.js +1 -0
  81. package/dist/store/runtime-clients/message-part-runtime-client.js.map +1 -1
  82. package/dist/store/scopes/part.d.ts +7 -0
  83. package/dist/store/scopes/part.d.ts.map +1 -1
  84. package/dist/subscribable/subscribable.d.ts.map +1 -1
  85. package/dist/subscribable/subscribable.js.map +1 -1
  86. package/dist/types/message.d.ts +6 -0
  87. package/dist/types/message.d.ts.map +1 -1
  88. package/dist/types/message.js.map +1 -1
  89. package/package.json +4 -4
  90. package/src/adapters/index.ts +1 -4
  91. package/src/index.ts +11 -0
  92. package/src/internal/duplicate-detection.ts +26 -0
  93. package/src/react/AssistantProvider.tsx +2 -3
  94. package/src/react/index.ts +2 -6
  95. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -2
  96. package/src/react/primitives/message/MessageAttachments.test.tsx +1 -1
  97. package/src/react/primitives/message/MessageGroupedParts.tsx +38 -31
  98. package/src/react/primitives/message/MessageParts.tsx +14 -1
  99. package/src/react/providers/TextMessagePartProvider.tsx +3 -0
  100. package/src/react/runtimes/external-message-converter.ts +26 -13
  101. package/src/react/types/MessagePartComponentTypes.ts +8 -0
  102. package/src/react/utils/groupParts.ts +67 -22
  103. package/src/runtime/api/attachment-runtime.ts +1 -2
  104. package/src/runtime/api/message-part-runtime.ts +26 -0
  105. package/src/runtime/base/base-thread-runtime-core.ts +4 -0
  106. package/src/runtime/interfaces/thread-runtime-core.ts +15 -0
  107. package/src/runtime/internal.ts +1 -4
  108. package/src/runtime/utils/thread-message-like.ts +7 -0
  109. package/src/runtimes/external-store/external-store-adapter.ts +37 -0
  110. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +1 -3
  111. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +168 -4
  112. package/src/runtimes/local/local-thread-runtime-core.ts +5 -0
  113. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +4 -0
  114. package/src/runtimes/remote-thread-list/empty-thread-core.ts +4 -0
  115. package/src/runtimes/tool-invocations/EDGE_CASES.md +194 -0
  116. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +1054 -0
  117. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +783 -0
  118. package/src/store/clients/thread-message-client.ts +3 -0
  119. package/src/store/runtime-clients/message-part-runtime-client.ts +2 -0
  120. package/src/store/scopes/part.ts +4 -0
  121. package/src/subscribable/subscribable.ts +3 -3
  122. package/src/tests/OptimisticState-delete-crash.test.ts +2 -0
  123. package/src/tests/OptimisticState-list-race.test.ts +2 -0
  124. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +5 -5
  125. package/src/tests/auiV0Encode.test.ts +1 -1
  126. package/src/tests/composer-can-send.test.ts +8 -4
  127. package/src/tests/duplicate-detection.test.ts +34 -0
  128. package/src/tests/external-store-thread-list-runtime-core.test.ts +1 -1
  129. package/src/tests/external-store-thread-runtime-core.test.ts +7 -6
  130. package/src/tests/groupParts.test.ts +118 -32
  131. package/src/tests/no-unsafe-process-env.test.ts +1 -0
  132. package/src/tests/remote-thread-list-isLoading.test.ts +2 -0
  133. package/src/tests/thread-message-like.test.ts +4 -1
  134. package/src/types/index.ts +1 -4
  135. package/src/types/message.ts +7 -0
  136. package/dist/react/runtimes/useToolInvocations.d.ts +0 -53
  137. package/dist/react/runtimes/useToolInvocations.d.ts.map +0 -1
  138. package/dist/react/runtimes/useToolInvocations.js +0 -380
  139. package/dist/react/runtimes/useToolInvocations.js.map +0 -1
  140. package/src/react/runtimes/useToolInvocations.ts +0 -694
@@ -1,3 +1,6 @@
1
+ import { isMcpAppUri } from "../../types/message";
2
+ import type { PartState } from "../../store/scopes/part";
3
+
1
4
  /**
2
5
  * Hierarchical adjacent-coalescing grouping for message parts.
3
6
  *
@@ -11,16 +14,71 @@
11
14
  */
12
15
 
13
16
  /**
14
- * Public group key type. Group keys must be prefixed with `group-` so
15
- * that a unified `switch (part.type)` in the renderer can distinguish
16
- * a group key (e.g. `"group-thought"`) from a real part type
17
- * (`"text"`, `"tool-call"`).
17
+ * Symbol attached to memoizable `groupBy` functions (e.g. those returned
18
+ * by {@link groupPartByType}). Carries a string fingerprint of the config
19
+ * so `MessagePrimitive.GroupedParts` can memo the tree on
20
+ * `[parts, memoKey]` across renders — even when the helper call site
21
+ * reconstructs the function each render.
22
+ */
23
+ export const GROUPBY_MEMO_KEY: unique symbol = Symbol.for(
24
+ "@assistant-ui/groupBy.memoKey",
25
+ );
26
+
27
+ /**
28
+ * Synthetic part-type key recognized by {@link groupPartByType}: a
29
+ * tool-call whose `mcp.app.resourceUri` points at an assistant-ui MCP
30
+ * app. Map this key to control how MCP-app tool calls are grouped —
31
+ * separately from regular `"tool-call"` parts.
32
+ */
33
+ type GroupPartType = PartState["type"] | "mcp-app";
34
+
35
+ /**
36
+ * Build a `groupBy` from a `part.type → group-key path` lookup.
37
+ * Parts whose type isn't in the map are left ungrouped. The returned
38
+ * function carries a stable {@link GROUPBY_MEMO_KEY} fingerprint so
39
+ * `<MessagePrimitive.GroupedParts>` can memoize its tree across renders.
40
+ *
41
+ * Special key `"mcp-app"` matches tool-call parts that point at an
42
+ * assistant-ui MCP app resource (`ui://...`) and takes precedence over
43
+ * the `"tool-call"` entry for those parts.
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * <MessagePrimitive.GroupedParts
48
+ * groupBy={groupPartByType({
49
+ * reasoning: ["group-thought", "group-reasoning"],
50
+ * "tool-call": ["group-thought", "group-tool"],
51
+ * "mcp-app": [],
52
+ * })}
53
+ * >
54
+ * {({ part, children }) => { ... }}
55
+ * </MessagePrimitive.GroupedParts>
56
+ * ```
18
57
  */
19
- export type GroupKey<TKey extends `group-${string}` = `group-${string}`> =
20
- | TKey
21
- | readonly TKey[]
22
- | null
23
- | undefined;
58
+ export const groupPartByType = <TKey extends `group-${string}`>(
59
+ map: Partial<Readonly<Record<GroupPartType, readonly TKey[]>>>,
60
+ ): ((part: PartState) => readonly TKey[]) => {
61
+ const lookup = map as Readonly<Record<string, readonly TKey[] | undefined>>;
62
+ const fn = ((part) => {
63
+ if (
64
+ part.type === "tool-call" &&
65
+ lookup["mcp-app"] !== undefined &&
66
+ isMcpAppUri(part.mcp?.app?.resourceUri)
67
+ ) {
68
+ return lookup["mcp-app"]!;
69
+ }
70
+ return lookup[part.type] ?? [];
71
+ }) as ((part: PartState) => readonly TKey[]) & {
72
+ [GROUPBY_MEMO_KEY]?: string;
73
+ };
74
+ // Sort keys so the fingerprint is insensitive to map insertion order —
75
+ // two maps with the same key/value pairs but different declaration order
76
+ // would otherwise hash differently and invalidate the memo unnecessarily.
77
+ const sortedKeys = Object.keys(map).sort();
78
+ const sortedEntries = sortedKeys.map((k) => [k, map[k as keyof typeof map]]);
79
+ fn[GROUPBY_MEMO_KEY] = `groupPartByType:${JSON.stringify(sortedEntries)}`;
80
+ return fn;
81
+ };
24
82
 
25
83
  export type GroupNode = GroupNodeGroup | GroupNodePart;
26
84
 
@@ -43,19 +101,6 @@ export interface GroupNodePart {
43
101
  readonly nodeKey: string;
44
102
  }
45
103
 
46
- const EMPTY_PATH: readonly string[] = Object.freeze([]);
47
-
48
- /**
49
- * Normalize a `groupBy` return value to a path array.
50
- * `null`/`undefined`/`[]` → `[]` (ungrouped).
51
- * `"foo"` → `["foo"]`. Arrays pass through.
52
- */
53
- export const normalizeGroupKey = (key: GroupKey): readonly string[] => {
54
- if (key == null) return EMPTY_PATH;
55
- if (typeof key === "string") return [key];
56
- return key;
57
- };
58
-
59
104
  interface BuildFrame {
60
105
  key: string;
61
106
  nodeKey: string;
@@ -42,8 +42,7 @@ export type AttachmentRuntime<
42
42
 
43
43
  export abstract class AttachmentRuntimeImpl<
44
44
  Source extends AttachmentRuntimeSource = AttachmentRuntimeSource,
45
- > implements AttachmentRuntime
46
- {
45
+ > implements AttachmentRuntime {
47
46
  public get path() {
48
47
  return this._core.path;
49
48
  }
@@ -26,6 +26,7 @@ type MessagePartSnapshotBinding = SubscribableWithState<
26
26
  export type MessagePartRuntime = {
27
27
  addToolResult(result: any | ToolResponse<any>): void;
28
28
  resumeToolCall(payload: unknown): void;
29
+ respondToToolApproval(response: { approved: boolean; reason?: string }): void;
29
30
 
30
31
  readonly path: MessagePartRuntimePath;
31
32
  getState(): MessagePartState;
@@ -48,6 +49,7 @@ export class MessagePartRuntimeImpl implements MessagePartRuntime {
48
49
  protected __internal_bindMethods() {
49
50
  this.addToolResult = this.addToolResult.bind(this);
50
51
  this.resumeToolCall = this.resumeToolCall.bind(this);
52
+ this.respondToToolApproval = this.respondToToolApproval.bind(this);
51
53
  this.getState = this.getState.bind(this);
52
54
  this.subscribe = this.subscribe.bind(this);
53
55
  }
@@ -102,6 +104,30 @@ export class MessagePartRuntimeImpl implements MessagePartRuntime {
102
104
  });
103
105
  }
104
106
 
107
+ public respondToToolApproval(response: {
108
+ approved: boolean;
109
+ reason?: string;
110
+ }) {
111
+ const state = this.contentBinding.getState();
112
+ if (!state) throw new Error("Message part is not available");
113
+
114
+ if (state.type !== "tool-call")
115
+ throw new Error(
116
+ "Tried to respond to tool approval on non-tool message part",
117
+ );
118
+
119
+ if (!state.approval || state.approval.approved !== undefined)
120
+ throw new Error("Tool call has no pending approval");
121
+
122
+ if (!this.threadApi) throw new Error("Thread API is not available");
123
+
124
+ this.threadApi.getState().respondToToolApproval({
125
+ approvalId: state.approval.id,
126
+ approved: response.approved,
127
+ ...(response.reason != null && { reason: response.reason }),
128
+ });
129
+ }
130
+
105
131
  public subscribe(callback: () => void) {
106
132
  return this.contentBinding.subscribe(callback);
107
133
  }
@@ -15,6 +15,7 @@ import { DefaultThreadComposerRuntimeCore } from "./default-thread-composer-runt
15
15
  import type {
16
16
  AddToolResultOptions,
17
17
  ResumeToolCallOptions,
18
+ RespondToToolApprovalOptions,
18
19
  ThreadSuggestion,
19
20
  SubmitFeedbackOptions,
20
21
  ThreadRuntimeCore,
@@ -59,6 +60,9 @@ export abstract class BaseThreadRuntimeCore implements ThreadRuntimeCore {
59
60
  public abstract resumeRun(config: ResumeRunConfig): void;
60
61
  public abstract addToolResult(options: AddToolResultOptions): void;
61
62
  public abstract resumeToolCall(options: ResumeToolCallOptions): void;
63
+ public abstract respondToToolApproval(
64
+ options: RespondToToolApprovalOptions,
65
+ ): void;
62
66
  public abstract cancelRun(): void;
63
67
  public abstract exportExternalState(): any;
64
68
  public abstract importExternalState(state: any): void;
@@ -1,3 +1,4 @@
1
+ import type { ToolModelContentPart } from "assistant-stream";
1
2
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
2
3
  import type { ModelContext } from "../../model-context/types";
3
4
  import type { Unsubscribe } from "../../types/unsubscribe";
@@ -38,6 +39,13 @@ export type AddToolResultOptions = {
38
39
  result: ReadonlyJSONValue;
39
40
  isError: boolean;
40
41
  artifact?: ReadonlyJSONValue | undefined;
42
+ /**
43
+ * Optional model-content payload produced by the tool. Populated when a
44
+ * client-side `execute()` or `streamCall` returns a `ToolResponse` with
45
+ * `modelContent`. Forwarded through `adapter.onAddToolResult` so the
46
+ * adapter can include it when sending the result back to its backend.
47
+ */
48
+ modelContent?: readonly ToolModelContentPart[] | undefined;
41
49
  };
42
50
 
43
51
  export type ResumeToolCallOptions = {
@@ -45,6 +53,12 @@ export type ResumeToolCallOptions = {
45
53
  payload: unknown;
46
54
  };
47
55
 
56
+ export type RespondToToolApprovalOptions = {
57
+ approvalId: string;
58
+ approved: boolean;
59
+ reason?: string;
60
+ };
61
+
48
62
  export type SubmitFeedbackOptions = {
49
63
  messageId: string;
50
64
  type: "negative" | "positive";
@@ -137,6 +151,7 @@ export type ThreadRuntimeCore = Readonly<{
137
151
 
138
152
  addToolResult: (options: AddToolResultOptions) => void;
139
153
  resumeToolCall: (options: ResumeToolCallOptions) => void;
154
+ respondToToolApproval: (options: RespondToToolApprovalOptions) => void;
140
155
 
141
156
  speak: (messageId: string) => void;
142
157
  stopSpeaking: () => void;
@@ -18,10 +18,7 @@ export { DefaultEditComposerRuntimeCore } from "./base/default-edit-composer-run
18
18
  // Runtime Impl Classes
19
19
  export { AssistantRuntimeImpl } from "./api/assistant-runtime";
20
20
 
21
- export {
22
- getThreadState,
23
- ThreadRuntimeImpl,
24
- } from "./api/thread-runtime";
21
+ export { getThreadState, ThreadRuntimeImpl } from "./api/thread-runtime";
25
22
  export type {
26
23
  ThreadRuntimeCoreBinding,
27
24
  ThreadListItemRuntimeBinding,
@@ -54,6 +54,13 @@ export type ThreadMessageLike = {
54
54
  readonly isError?: boolean | undefined;
55
55
  readonly parentId?: string | undefined;
56
56
  readonly messages?: readonly ThreadMessage[] | undefined;
57
+ readonly interrupt?: { type: "human"; payload: unknown };
58
+ readonly approval?: {
59
+ readonly id: string;
60
+ readonly approved?: boolean;
61
+ readonly reason?: string;
62
+ readonly isAutomatic?: boolean;
63
+ };
57
64
  }
58
65
  )[];
59
66
  readonly id?: string | undefined;
@@ -9,12 +9,14 @@ import type { RealtimeVoiceAdapter } from "../../adapters/voice";
9
9
  import type { FeedbackAdapter } from "../../adapters/feedback";
10
10
  import type {
11
11
  AddToolResultOptions,
12
+ RespondToToolApprovalOptions,
12
13
  StartRunConfig,
13
14
  ResumeRunConfig,
14
15
  ThreadSuggestion,
15
16
  } from "../../runtime/interfaces/thread-runtime-core";
16
17
  import type { ExportedMessageRepository } from "../../runtime/utils/message-repository";
17
18
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
19
+ import type { ToolExecutionStatus } from "../tool-invocations/ToolInvocationTracker";
18
20
 
19
21
  export type ExternalStoreThreadData<TState extends "regular" | "archived"> = {
20
22
  status: TState;
@@ -108,6 +110,9 @@ type ExternalStoreAdapterBase<T> = {
108
110
  onResumeToolCall?:
109
111
  | ((options: { toolCallId: string; payload: unknown }) => void)
110
112
  | undefined;
113
+ onRespondToToolApproval?:
114
+ | ((options: RespondToToolApprovalOptions) => Promise<void> | void)
115
+ | undefined;
111
116
  convertMessage?: ExternalStoreMessageConverter<T> | undefined;
112
117
  adapters?:
113
118
  | {
@@ -127,6 +132,38 @@ type ExternalStoreAdapterBase<T> = {
127
132
  copy?: boolean | undefined;
128
133
  }
129
134
  | undefined;
135
+ /**
136
+ * Opt in to the built-in client-side tool-invocations pipeline
137
+ * (`streamCall` / `execute` / tool-status tracking) for this thread.
138
+ *
139
+ * Defaults to `false` — the runtime does *not* drive client-side tool
140
+ * callbacks on its own. Set to `true` to have the runtime construct a
141
+ * `ToolInvocationTracker` and feed every snapshot through it, so tool
142
+ * callbacks fire automatically for tool-call parts in `messages`.
143
+ *
144
+ * Opt-in by default because most external-store runtimes either run
145
+ * tools entirely server-side, or already wire their own client-side
146
+ * dispatch path. Enabling the embedded tracker on top of an existing
147
+ * dispatch path would cause tool callbacks to run twice.
148
+ *
149
+ * When enabled, client-side tool results (from `execute()` returning,
150
+ * or from `streamCall` resolving) flow back through
151
+ * `adapter.onAddToolResult` like any other tool result, with
152
+ * `modelContent` populated when present.
153
+ */
154
+ unstable_enableToolInvocations?: boolean | undefined;
155
+ /**
156
+ * Receives the current per-tool-call execution status map whenever it
157
+ * changes. Only invoked when `unstable_enableToolInvocations` is `true`
158
+ * — the runtime maintains the map via the embedded tracker.
159
+ *
160
+ * Wire this into local React state and feed it into the converter's
161
+ * `metadata.toolStatuses` so the UI can render `executing` spinners
162
+ * and human-input prompts.
163
+ */
164
+ setToolStatuses?:
165
+ | ((statuses: Record<string, ToolExecutionStatus>) => void)
166
+ | undefined;
130
167
  };
131
168
 
132
169
  export type ExternalStoreAdapter<T = ThreadMessage> =
@@ -22,9 +22,7 @@ const DEFAULT_THREAD_DATA = Object.freeze({
22
22
  [DEFAULT_THREAD_ID]: DEFAULT_THREAD,
23
23
  });
24
24
 
25
- export class ExternalStoreThreadListRuntimeCore
26
- implements ThreadListRuntimeCore
27
- {
25
+ export class ExternalStoreThreadListRuntimeCore implements ThreadListRuntimeCore {
28
26
  private _mainThreadId: string = DEFAULT_THREAD_ID;
29
27
  private _threads: readonly string[] = DEFAULT_THREADS;
30
28
  private _archivedThreads: readonly string[] = EMPTY_ARRAY;
@@ -3,6 +3,7 @@ import type {
3
3
  AddToolResultOptions,
4
4
  ResumeRunConfig,
5
5
  ResumeToolCallOptions,
6
+ RespondToToolApprovalOptions,
6
7
  StartRunConfig,
7
8
  ThreadSuggestion,
8
9
  } from "../../runtime/interfaces/thread-runtime-core";
@@ -29,6 +30,7 @@ import {
29
30
  ExportedMessageRepository,
30
31
  MessageRepository,
31
32
  } from "../../runtime/utils/message-repository";
33
+ import { ToolInvocationTracker } from "../tool-invocations/ToolInvocationTracker";
32
34
 
33
35
  const EMPTY_ARRAY: readonly ThreadSuggestion[] = Object.freeze([]);
34
36
 
@@ -104,6 +106,12 @@ export class ExternalStoreThreadRuntimeCore
104
106
 
105
107
  private _store!: ExternalStoreAdapter<any>;
106
108
 
109
+ /**
110
+ * Client-side tool-invocations pipeline. Constructed lazily on first
111
+ * snapshot — only when `adapter.unstable_enableToolInvocations === true`.
112
+ */
113
+ private _toolInvocations: ToolInvocationTracker | null = null;
114
+
107
115
  public override beginEdit(messageId: string) {
108
116
  if (!this._store.onEdit)
109
117
  throw new Error("Runtime does not support editing.");
@@ -272,9 +280,113 @@ export class ExternalStoreThreadRuntimeCore
272
280
  );
273
281
 
274
282
  this._messages = this.repository.getMessages();
283
+
284
+ this._driveToolInvocations();
285
+
275
286
  this._notifySubscribers();
276
287
  }
277
288
 
289
+ /**
290
+ * Feed the current message snapshot into the tool-invocations tracker.
291
+ * Opt-in via `adapter.unstable_enableToolInvocations: true`. The tracker
292
+ * itself is fail-silent — see ToolInvocationTracker for the
293
+ * state-transition contract.
294
+ */
295
+ private _driveToolInvocations(): void {
296
+ if (!this._store.unstable_enableToolInvocations) {
297
+ // Adapter did not opt in (default). If a tracker was previously
298
+ // constructed (e.g. the adapter just toggled the flag off via a
299
+ // dynamic swap), drop it so subsequent snapshots are no-ops.
300
+ if (this._toolInvocations) {
301
+ this._toolInvocations.reset();
302
+ this._toolInvocations = null;
303
+ this._store.setToolStatuses?.({});
304
+ }
305
+ return;
306
+ }
307
+
308
+ if (!this._toolInvocations) {
309
+ this._toolInvocations = new ToolInvocationTracker(
310
+ () => this.getModelContext().tools,
311
+ {
312
+ onResult: (command) => {
313
+ try {
314
+ const messageId = this._findMessageIdForToolCall(
315
+ command.toolCallId,
316
+ );
317
+ if (messageId === undefined) {
318
+ // The tool call no longer exists in the snapshot (e.g.
319
+ // rolled back). Drop the result.
320
+ return;
321
+ }
322
+ this._store.onAddToolResult?.({
323
+ messageId,
324
+ toolCallId: command.toolCallId,
325
+ toolName: command.toolName,
326
+ result: command.result,
327
+ isError: command.isError,
328
+ ...(command.artifact !== undefined && {
329
+ artifact: command.artifact,
330
+ }),
331
+ ...(command.modelContent !== undefined && {
332
+ modelContent: command.modelContent,
333
+ }),
334
+ });
335
+ } catch (err) {
336
+ console.error(
337
+ "[ExternalStoreThreadRuntimeCore] onAddToolResult dispatch failed",
338
+ err,
339
+ );
340
+ }
341
+ },
342
+ onStatusesChange: (statuses) => {
343
+ this._store.setToolStatuses?.(Object.fromEntries(statuses));
344
+ },
345
+ },
346
+ );
347
+ }
348
+
349
+ this._toolInvocations.setState({
350
+ messages: this._messages,
351
+ isRunning: this._store.isRunning ?? false,
352
+ ...(this._store.isLoading !== undefined && {
353
+ isLoading: this._store.isLoading,
354
+ }),
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Lookup table from `toolCallId` to the owning assistant message's `id`,
360
+ * rebuilt lazily when `_messages` changes (see `_messagesForToolCallIndex`).
361
+ */
362
+ private _toolCallToMessageId = new Map<string, string>();
363
+ private _messagesForToolCallIndex: readonly ThreadMessage[] | null = null;
364
+
365
+ /**
366
+ * Look up the assistant message that owns a tool-call part. Lazily builds
367
+ * (and caches) a `toolCallId → messageId` map keyed off the current
368
+ * `_messages` reference, so onResult dispatches stay O(1) instead of
369
+ * walking the full thread on every result.
370
+ */
371
+ private _findMessageIdForToolCall(toolCallId: string): string | undefined {
372
+ if (this._messagesForToolCallIndex !== this._messages) {
373
+ this._toolCallToMessageId.clear();
374
+ const visit = (messages: readonly ThreadMessage[]): void => {
375
+ for (const message of messages) {
376
+ if (!Array.isArray(message.content)) continue;
377
+ for (const part of message.content) {
378
+ if (!part || part.type !== "tool-call") continue;
379
+ this._toolCallToMessageId.set(part.toolCallId, message.id);
380
+ if (part.messages) visit(part.messages);
381
+ }
382
+ }
383
+ };
384
+ visit(this._messages);
385
+ this._messagesForToolCallIndex = this._messages;
386
+ }
387
+ return this._toolCallToMessageId.get(toolCallId);
388
+ }
389
+
278
390
  public override switchToBranch(branchId: string): void {
279
391
  if (!this._store.setMessages)
280
392
  throw new Error("Runtime does not support switching branches.");
@@ -289,6 +401,16 @@ export class ExternalStoreThreadRuntimeCore
289
401
  }
290
402
 
291
403
  public async append(message: AppendMessage): Promise<void> {
404
+ // Auto-abort in-flight client-side tool executions when a new run is
405
+ // about to start. Without this, a tool that finishes after the new turn
406
+ // begins would feed a stale result into `onAddToolResult`, racing with
407
+ // the new turn the user just initiated. `startRun` defaults to true for
408
+ // user messages — matches the satellites' historical opt-in cancel
409
+ // behavior, which is now built in.
410
+ if (message.startRun ?? message.role === "user") {
411
+ await this._toolInvocations?.abort();
412
+ }
413
+
292
414
  if (message.parentId !== (this.messages.at(-1)?.id ?? null)) {
293
415
  if (!this._store.onEdit)
294
416
  throw new Error("Runtime does not support editing messages.");
@@ -302,6 +424,11 @@ export class ExternalStoreThreadRuntimeCore
302
424
  if (!this._store.onReload)
303
425
  throw new Error("Runtime does not support reloading messages.");
304
426
 
427
+ // Auto-abort in-flight client-side tool executions when a run reloads;
428
+ // any results that land afterward would target a turn that no longer
429
+ // exists. See `append` above for full rationale.
430
+ await this._toolInvocations?.abort();
431
+
305
432
  await this._store.onReload(config.parentId, config);
306
433
  }
307
434
 
@@ -323,6 +450,18 @@ export class ExternalStoreThreadRuntimeCore
323
450
  if (!this._store.onLoadExternalState)
324
451
  throw new Error("Runtime does not support importing external states.");
325
452
 
453
+ // Re-arm the tracker so the next adapter snapshot (containing the
454
+ // imported state) is treated as historical — no streamCall/execute
455
+ // fires for the loaded tool calls. The adapter is expected to update
456
+ // its messages in response to onLoadExternalState; that update flows
457
+ // back here via __internal_setAdapter. We only clear adapter-side
458
+ // tool statuses when the tracker is the source of truth — otherwise
459
+ // we'd wipe statuses the adapter is managing on its own.
460
+ if (this._toolInvocations) {
461
+ this._toolInvocations.reset();
462
+ this._store.setToolStatuses?.({});
463
+ }
464
+
326
465
  this._store.onLoadExternalState(state);
327
466
  }
328
467
 
@@ -330,6 +469,11 @@ export class ExternalStoreThreadRuntimeCore
330
469
  if (!this._store.onCancel)
331
470
  throw new Error("Runtime does not support cancelling runs.");
332
471
 
472
+ // Abort any in-flight client-side tool executions. Fire-and-forget —
473
+ // the abort resolves once executions settle, but we don't gate the
474
+ // cancel on it.
475
+ void this._toolInvocations?.abort();
476
+
333
477
  this._store.onCancel();
334
478
 
335
479
  if (this._assistantOptimisticId) {
@@ -361,15 +505,35 @@ export class ExternalStoreThreadRuntimeCore
361
505
  }
362
506
 
363
507
  public addToolResult(options: AddToolResultOptions) {
364
- if (!this._store.onAddToolResult && !this._store.onAddToolResult)
508
+ if (!this._store.onAddToolResult)
365
509
  throw new Error("Runtime does not support tool results.");
366
510
  this._store.onAddToolResult?.(options);
367
511
  }
368
512
 
369
513
  public resumeToolCall(options: ResumeToolCallOptions) {
370
- if (!this._store.onResumeToolCall)
371
- throw new Error("Runtime does not support resuming tool calls.");
372
- this._store.onResumeToolCall(options);
514
+ // Tracker owns its own human-input handlers — let it resume in-process
515
+ // tool calls without round-tripping through the adapter. Falls back to
516
+ // the adapter's onResumeToolCall (if any) for tool calls the tracker
517
+ // doesn't know about.
518
+ const handled =
519
+ this._toolInvocations?.resume(options.toolCallId, options.payload) ??
520
+ false;
521
+ if (handled) return;
522
+
523
+ if (this._store.onResumeToolCall) {
524
+ this._store.onResumeToolCall(options);
525
+ return;
526
+ }
527
+
528
+ throw new Error(
529
+ `Tool call ${options.toolCallId} is not waiting for resume.`,
530
+ );
531
+ }
532
+
533
+ public respondToToolApproval(options: RespondToToolApprovalOptions) {
534
+ if (!this._store.onRespondToToolApproval)
535
+ throw new Error("Runtime does not support tool approvals.");
536
+ this._store.onRespondToToolApproval(options);
373
537
  }
374
538
 
375
539
  public override reset(initialMessages?: readonly ThreadMessageLike[]) {
@@ -9,6 +9,7 @@ import type { LocalRuntimeOptionsBase } from "./local-runtime-options";
9
9
  import type {
10
10
  AddToolResultOptions,
11
11
  ResumeToolCallOptions,
12
+ RespondToToolApprovalOptions,
12
13
  ThreadSuggestion,
13
14
  ThreadRuntimeCore,
14
15
  StartRunConfig,
@@ -535,4 +536,8 @@ export class LocalThreadRuntimeCore
535
536
  public resumeToolCall(_options: ResumeToolCallOptions) {
536
537
  throw new Error("Local runtime does not support resuming tool calls.");
537
538
  }
539
+
540
+ public respondToToolApproval(_options: RespondToToolApprovalOptions) {
541
+ throw new Error("Local runtime does not support tool approvals.");
542
+ }
538
543
  }
@@ -65,6 +65,10 @@ export class ReadonlyThreadRuntimeCore
65
65
  throw READONLY_THREAD_ERROR;
66
66
  }
67
67
 
68
+ respondToToolApproval(): void {
69
+ throw READONLY_THREAD_ERROR;
70
+ }
71
+
68
72
  speak(): void {
69
73
  throw READONLY_THREAD_ERROR;
70
74
  }
@@ -40,6 +40,10 @@ export const EMPTY_THREAD_CORE: ThreadRuntimeCore = {
40
40
  throw EMPTY_THREAD_ERROR;
41
41
  },
42
42
 
43
+ respondToToolApproval() {
44
+ throw EMPTY_THREAD_ERROR;
45
+ },
46
+
43
47
  speak() {
44
48
  throw EMPTY_THREAD_ERROR;
45
49
  },