@assistant-ui/core 0.2.6 → 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 (70) hide show
  1. package/dist/index.d.ts +3 -1
  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 +2 -2
  11. package/dist/react/index.js +1 -2
  12. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  13. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  14. package/dist/react/runtimes/external-message-converter.d.ts +1 -1
  15. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  16. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  17. package/dist/runtime/api/attachment-runtime.js.map +1 -1
  18. package/dist/runtime/interfaces/thread-runtime-core.d.ts +8 -0
  19. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  20. package/dist/runtimes/external-store/external-store-adapter.d.ts +31 -0
  21. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  22. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  23. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  24. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +25 -0
  25. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  26. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +94 -3
  27. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  28. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts +168 -0
  29. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -0
  30. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +449 -0
  31. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -0
  32. package/dist/subscribable/subscribable.d.ts.map +1 -1
  33. package/dist/subscribable/subscribable.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/adapters/index.ts +1 -4
  36. package/src/index.ts +10 -0
  37. package/src/internal/duplicate-detection.ts +26 -0
  38. package/src/react/AssistantProvider.tsx +2 -3
  39. package/src/react/index.ts +1 -6
  40. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -2
  41. package/src/react/primitives/message/MessageAttachments.test.tsx +1 -1
  42. package/src/react/primitives/message/MessageGroupedParts.tsx +1 -1
  43. package/src/react/runtimes/external-message-converter.ts +1 -1
  44. package/src/runtime/api/attachment-runtime.ts +1 -2
  45. package/src/runtime/interfaces/thread-runtime-core.ts +8 -0
  46. package/src/runtime/internal.ts +1 -4
  47. package/src/runtimes/external-store/external-store-adapter.ts +33 -0
  48. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +1 -3
  49. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +161 -4
  50. package/src/runtimes/tool-invocations/EDGE_CASES.md +194 -0
  51. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +1054 -0
  52. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +783 -0
  53. package/src/subscribable/subscribable.ts +3 -3
  54. package/src/tests/OptimisticState-delete-crash.test.ts +2 -0
  55. package/src/tests/OptimisticState-list-race.test.ts +2 -0
  56. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +5 -5
  57. package/src/tests/auiV0Encode.test.ts +1 -1
  58. package/src/tests/composer-can-send.test.ts +8 -4
  59. package/src/tests/duplicate-detection.test.ts +34 -0
  60. package/src/tests/external-store-thread-list-runtime-core.test.ts +1 -1
  61. package/src/tests/external-store-thread-runtime-core.test.ts +7 -6
  62. package/src/tests/no-unsafe-process-env.test.ts +1 -0
  63. package/src/tests/remote-thread-list-isLoading.test.ts +2 -0
  64. package/src/tests/thread-message-like.test.ts +4 -1
  65. package/src/types/index.ts +1 -4
  66. package/dist/react/runtimes/useToolInvocations.d.ts +0 -53
  67. package/dist/react/runtimes/useToolInvocations.d.ts.map +0 -1
  68. package/dist/react/runtimes/useToolInvocations.js +0 -380
  69. package/dist/react/runtimes/useToolInvocations.js.map +0 -1
  70. package/src/react/runtimes/useToolInvocations.ts +0 -694
@@ -0,0 +1,26 @@
1
+ // Warns once if a second copy of @assistant-ui/core is loaded into the
2
+ // same runtime. Mismatched transitive versions of core silently break
3
+ // runtime behavior — tools registered via `makeAssistantTool` don't reach
4
+ // the active runtime, context lookups resolve to the wrong provider,
5
+ // `instanceof` checks fail (see issue #4101). The actual version diagnosis
6
+ // lives in `npx assistant-ui doctor`.
7
+ //
8
+ // The caller is responsible for gating on `process.env.NODE_ENV` so this
9
+ // module tree-shakes out of production bundles.
10
+
11
+ const KEY = Symbol.for("@assistant-ui/core.loaded");
12
+
13
+ export function checkDuplicateCore(): void {
14
+ const g = globalThis as unknown as Record<symbol, boolean | undefined>;
15
+ if (g[KEY]) {
16
+ // eslint-disable-next-line no-console
17
+ console.warn(
18
+ "[@assistant-ui/core] Multiple copies of @assistant-ui/core are " +
19
+ "loaded into the same runtime. This causes subtle bugs (tools not " +
20
+ "reaching the runtime, context lookups returning the wrong " +
21
+ "provider, instanceof checks failing). Run " +
22
+ "`npx assistant-ui doctor` to diagnose.",
23
+ );
24
+ }
25
+ g[KEY] = true;
26
+ }
@@ -10,9 +10,8 @@ import type { AssistantRuntimeCore } from "../runtime/interfaces/assistant-runti
10
10
  import { RuntimeAdapter } from "./RuntimeAdapter";
11
11
 
12
12
  export const getRenderComponent = (runtime: AssistantRuntime) => {
13
- return (runtime as { _core?: AssistantRuntimeCore })._core?.RenderComponent as
14
- | ComponentType
15
- | undefined;
13
+ return (runtime as { _core?: AssistantRuntimeCore })._core
14
+ ?.RenderComponent as ComponentType | undefined;
16
15
  };
17
16
 
18
17
  export type AssistantProviderBaseProps = PropsWithChildren<{
@@ -1,3 +1,4 @@
1
+ /// <reference path="../store/scope-registration.ts" />
1
2
  /// <reference path="./types/store-augmentation.ts" />
2
3
 
3
4
  // model-context
@@ -129,12 +130,6 @@ export {
129
130
  useRuntimeAdapters,
130
131
  type RuntimeAdapters,
131
132
  } from "./runtimes/RuntimeAdapterProvider";
132
- export {
133
- useToolInvocations,
134
- type ToolExecutionStatus,
135
- type AssistantTransportState,
136
- type AddToolResultCommand,
137
- } from "./runtimes/useToolInvocations";
138
133
  export { useExternalStoreRuntime } from "./runtimes/useExternalStoreRuntime";
139
134
  export {
140
135
  useExternalMessageConverter,
@@ -86,8 +86,7 @@ export const ChainOfThoughtPrimitiveParts: FC<
86
86
  );
87
87
  }
88
88
 
89
- // eslint-disable-next-line react-hooks/exhaustive-deps
90
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
89
+ // oxlint-disable-next-line react-hooks/rules-of-hooks -- intentional conditional hook below the early return above
91
90
  const messageComponents = useMemo(
92
91
  () => ({
93
92
  Reasoning: components?.Reasoning,
@@ -4,7 +4,7 @@ import { MessagePrimitiveAttachments } from "./MessageAttachments";
4
4
 
5
5
  const mockUseAuiState = vi.fn();
6
6
  type UseAuiStateSelector = Parameters<
7
- typeof import("@assistant-ui/store")["useAuiState"]
7
+ (typeof import("@assistant-ui/store"))["useAuiState"]
8
8
  >[0];
9
9
  type AttachmentsElement = ReactElement<{ children: () => null }>;
10
10
 
@@ -182,9 +182,9 @@ export const MessagePrimitiveGroupedParts = <TKey extends `group-${string}`>({
182
182
  GROUPBY_MEMO_KEY
183
183
  ];
184
184
  const memoDep = memoKey ?? groupBy;
185
- // biome-ignore lint/correctness/useExhaustiveDependencies: groupBy is captured via memoDep — either as its identity (no memoKey) or as the helper's memoKey fingerprint. Listing groupBy directly would defeat the helper-tagged memo path.
186
185
  const tree = useMemo(
187
186
  () => buildGroupTree(parts.map((part) => groupBy(part) ?? [])),
187
+ // oxlint-disable-next-line tap-hooks/exhaustive-deps -- groupBy is captured via memoDep (either its identity or the helper's memoKey fingerprint); listing it directly would defeat the helper-tagged memo path
188
188
  [parts, memoDep],
189
189
  );
190
190
 
@@ -12,7 +12,7 @@ import {
12
12
  type ThreadMessageLike,
13
13
  } from "../../runtime/utils/thread-message-like";
14
14
  import { getAutoStatus, isAutoStatus } from "../../runtime/utils/auto-status";
15
- import type { ToolExecutionStatus } from "./useToolInvocations";
15
+ import type { ToolExecutionStatus } from "../../runtimes/tool-invocations/ToolInvocationTracker";
16
16
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
17
17
  import { generateErrorMessageId } from "../../utils/id";
18
18
  import type {
@@ -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
  }
@@ -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 = {
@@ -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,
@@ -16,6 +16,7 @@ import type {
16
16
  } from "../../runtime/interfaces/thread-runtime-core";
17
17
  import type { ExportedMessageRepository } from "../../runtime/utils/message-repository";
18
18
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
19
+ import type { ToolExecutionStatus } from "../tool-invocations/ToolInvocationTracker";
19
20
 
20
21
  export type ExternalStoreThreadData<TState extends "regular" | "archived"> = {
21
22
  status: TState;
@@ -131,6 +132,38 @@ type ExternalStoreAdapterBase<T> = {
131
132
  copy?: boolean | undefined;
132
133
  }
133
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;
134
167
  };
135
168
 
136
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;
@@ -30,6 +30,7 @@ import {
30
30
  ExportedMessageRepository,
31
31
  MessageRepository,
32
32
  } from "../../runtime/utils/message-repository";
33
+ import { ToolInvocationTracker } from "../tool-invocations/ToolInvocationTracker";
33
34
 
34
35
  const EMPTY_ARRAY: readonly ThreadSuggestion[] = Object.freeze([]);
35
36
 
@@ -105,6 +106,12 @@ export class ExternalStoreThreadRuntimeCore
105
106
 
106
107
  private _store!: ExternalStoreAdapter<any>;
107
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
+
108
115
  public override beginEdit(messageId: string) {
109
116
  if (!this._store.onEdit)
110
117
  throw new Error("Runtime does not support editing.");
@@ -273,9 +280,113 @@ export class ExternalStoreThreadRuntimeCore
273
280
  );
274
281
 
275
282
  this._messages = this.repository.getMessages();
283
+
284
+ this._driveToolInvocations();
285
+
276
286
  this._notifySubscribers();
277
287
  }
278
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
+
279
390
  public override switchToBranch(branchId: string): void {
280
391
  if (!this._store.setMessages)
281
392
  throw new Error("Runtime does not support switching branches.");
@@ -290,6 +401,16 @@ export class ExternalStoreThreadRuntimeCore
290
401
  }
291
402
 
292
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
+
293
414
  if (message.parentId !== (this.messages.at(-1)?.id ?? null)) {
294
415
  if (!this._store.onEdit)
295
416
  throw new Error("Runtime does not support editing messages.");
@@ -303,6 +424,11 @@ export class ExternalStoreThreadRuntimeCore
303
424
  if (!this._store.onReload)
304
425
  throw new Error("Runtime does not support reloading messages.");
305
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
+
306
432
  await this._store.onReload(config.parentId, config);
307
433
  }
308
434
 
@@ -324,6 +450,18 @@ export class ExternalStoreThreadRuntimeCore
324
450
  if (!this._store.onLoadExternalState)
325
451
  throw new Error("Runtime does not support importing external states.");
326
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
+
327
465
  this._store.onLoadExternalState(state);
328
466
  }
329
467
 
@@ -331,6 +469,11 @@ export class ExternalStoreThreadRuntimeCore
331
469
  if (!this._store.onCancel)
332
470
  throw new Error("Runtime does not support cancelling runs.");
333
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
+
334
477
  this._store.onCancel();
335
478
 
336
479
  if (this._assistantOptimisticId) {
@@ -362,15 +505,29 @@ export class ExternalStoreThreadRuntimeCore
362
505
  }
363
506
 
364
507
  public addToolResult(options: AddToolResultOptions) {
365
- if (!this._store.onAddToolResult && !this._store.onAddToolResult)
508
+ if (!this._store.onAddToolResult)
366
509
  throw new Error("Runtime does not support tool results.");
367
510
  this._store.onAddToolResult?.(options);
368
511
  }
369
512
 
370
513
  public resumeToolCall(options: ResumeToolCallOptions) {
371
- if (!this._store.onResumeToolCall)
372
- throw new Error("Runtime does not support resuming tool calls.");
373
- 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
+ );
374
531
  }
375
532
 
376
533
  public respondToToolApproval(options: RespondToToolApprovalOptions) {
@@ -0,0 +1,194 @@
1
+ # `ToolInvocationTracker` — known state-transition edge cases
2
+
3
+ This document captures the non-trivial state transitions the tracker may
4
+ observe via `setState(snapshot)` and what the current behavior is.
5
+
6
+ ## Hard contract
7
+
8
+ > **`streamCall` (and `execute`) fires exactly once per logical
9
+ > `toolCallId`.** No matter how the host's snapshot mutates after that
10
+ > first observation — args regress, args change after first completion,
11
+ > result is replaced, result is cleared, key order shuffles — the tracker
12
+ > never invokes the host's tool callback a second time.
13
+
14
+ This guarantees host-side side effects (the typical reason `streamCall` /
15
+ `execute` exists at all) can't double-run. The cost: post-completion
16
+ mutations are not surfaced to the host through the tool callback.
17
+ Consumers that need to observe them will opt into the planned
18
+ `reader.events()` API.
19
+
20
+ The tracker also never throws. Every public method that observes runtime
21
+ state (`setState`, `reset`, `abort`, `resume`) wraps its work in
22
+ try/catch and logs to `console.error`. The tracker is built into the hot
23
+ message-processing path; a malformed snapshot must never crash the host
24
+ runtime.
25
+
26
+ ## A. Tool changes shape after first observation
27
+
28
+ ### A.1. Args grow (normal streaming case)
29
+ Each snapshot's `argsText` is a longer prefix of the previous. The
30
+ tracker appends the delta into the active controller's `argsText`
31
+ stream. No re-fire.
32
+
33
+ ### A.2. Args regress mid-stream (snapshot regression)
34
+ A later snapshot's `argsText` is shorter than what we already streamed,
35
+ or otherwise *not* a prefix of it. Under the exactly-once contract, the
36
+ tracker does **not** restart the stream. The controller keeps whatever
37
+ prefix already streamed. The regression is logged in non-prod. The
38
+ host's view diverges from the snapshot until `reader.events()` ships.
39
+
40
+ Subsequent snapshots that *are* prefixes of the new (regressed) snapshot
41
+ also won't be appended, because `entry.argsText` still points at the
42
+ pre-regression value used for delta calculation.
43
+
44
+ ### A.3. Args complete then equivalent-JSON key reorder
45
+ Both old and new `argsText` parse to equivalent JSON values (e.g. keys
46
+ reordered by the backend). The tracker updates its tracked `argsText`
47
+ silently. No re-fire.
48
+
49
+ ### A.4. Args complete then change to non-equivalent value
50
+ The tracker does **not** restart the stream and does **not** invoke
51
+ `streamCall` a second time. Logs the divergence in non-prod. The host's
52
+ existing `streamCall` keeps its original args view.
53
+
54
+ ### A.5. First resolution (`result` becomes defined)
55
+ The tracker calls `setResponse` on the active controller and closes it.
56
+ `reader.response.get()` resolves. If the tool also had a frontend
57
+ `execute`, the executor is short-circuited via `_skipExecuteStreamIds`.
58
+ Single fire.
59
+
60
+ ### A.6. Previously-resolved tool's `result` is replaced
61
+ Silently ignored — `entry.hasResult` short-circuits both the
62
+ re-`setResponse` path and the downstream result-chunk handler. The host
63
+ sees only the first result.
64
+
65
+ ### A.7. Previously-resolved tool loses its `result` (back to undefined)
66
+ Silently ignored. The entry stays in the resolved phase internally.
67
+
68
+ ## B. Tool call disappears from snapshot
69
+
70
+ ### B.1. Tool call removed entirely (rollback, branch switch)
71
+ The tracker does not auto-clean entries that disappear from the
72
+ snapshot. The entry persists in `_entries` until the next `reset()`.
73
+
74
+ Auto-cleanup is intentionally avoided: if the same `toolCallId` ever
75
+ reappears in a later snapshot, treating it as new would re-fire
76
+ `streamCall`, violating the exactly-once contract. The cost is a bounded
77
+ memory accumulation across the tracker's lifetime; `reset()` clears it.
78
+
79
+ ## C. Initial snapshot vs. live snapshot
80
+
81
+ ### C.1. Tool call present in the initial snapshot
82
+ While `_pendingRestore === true` (either by construction, or because
83
+ `snapshot.isLoading === true`), tool calls are recorded as restored
84
+ entries with no controller. `streamCall` / `execute` do not fire.
85
+
86
+ ### C.2. Restored entry observed in a live snapshot, unchanged
87
+ Silently kept as restored. Recursion into `content.messages` still
88
+ happens so any nested live tool calls are processed.
89
+
90
+ ### C.3. Restored entry observed in a live snapshot, signature changed
91
+ The restored entry is deleted and a new active entry starts via
92
+ `_startActiveEntry`. This is PR #4057's promotion path. `streamCall`
93
+ fires once — its first and only fire for this `toolCallId`.
94
+
95
+ ### C.4. `isLoading` transitions `true → false` while messages are stable
96
+ The next `setState` call sees `isLoading === false` and processes
97
+ messages as live. Snapshots observed while `isLoading` was true seeded
98
+ restored entries. The first live snapshot promotes any whose signature
99
+ changed.
100
+
101
+ ### C.5. `isLoading` transitions `false → true` mid-session
102
+ Treated as a return to the historical-loading window. Subsequent
103
+ snapshots are recorded as restored. Tool calls observed live before the
104
+ transition keep their active controllers — the tracker does not unwind
105
+ them.
106
+
107
+ ## D. Nested tool calls (PTC sub-tools via `content.messages`)
108
+
109
+ ### D.1. Parent tool's nested messages are observed
110
+ The tracker recurses via `_processMessages(content.messages)`. Nested
111
+ tool calls go through the same restore / live / promotion logic as
112
+ top-level ones, all under the same exactly-once contract.
113
+
114
+ ### D.2. Nested tool's parent gets a new `result`
115
+ Handled like A.5 for the parent; the recursion into `content.messages`
116
+ still runs in the same pass, so nested tool calls also get processed.
117
+
118
+ ### D.3. Nested tool's `content.messages` itself changes
119
+ Identity is by `toolCallId`, not index. A different `toolCallId` at
120
+ the same nested position is a fresh tool call. Same id with different
121
+ shape goes through A.1–A.4.
122
+
123
+ ## E. Malformed snapshot
124
+
125
+ ### E.1. `message` is null/undefined or `message.content` is not an array
126
+ Skipped silently. The rest of the snapshot still processes.
127
+
128
+ ### E.2. `content` item is null or not a tool-call part
129
+ Skipped silently. Other parts in the same `message.content` still process.
130
+
131
+ ### E.3. Different `messages` reference, identical contents
132
+ The tracker re-walks the array on every non-identity snapshot. The
133
+ reference-equality fast path in `setState` rarely fires for class
134
+ consumers (external-store rebuilds the array on every adapter update).
135
+
136
+ ### E.4. `setState` throws inside `_processMessages`
137
+ The top-level try/catch in `setState` swallows the error and logs.
138
+ `_lastSnapshot` and `_isRunning` mutations are deferred until *after*
139
+ successful processing, so a transient failure does not corrupt the
140
+ tracker's view of "what we last observed". The next snapshot retries.
141
+
142
+ ## F. Concurrency and lifecycle
143
+
144
+ ### F.1. `reset()` called while `execute()` invocations are in flight
145
+ `abort()` is invoked, in-flight executions reject with
146
+ `Tool execution aborted`. Once they settle, the cleanup logic clears
147
+ `_executing`. The settled-resolver promises fire so the abort promise
148
+ resolves.
149
+
150
+ ### F.2. `setState` called during `reset()`'s in-flight abort
151
+ The new snapshot is processed against an empty `_entries`. Tool calls
152
+ in it are seeded as restored (because `reset()` re-armed
153
+ `_pendingRestore`). Eventual cancellation `result` chunks for the
154
+ aborted executions are dropped via `_skipExecuteStreamIds`.
155
+
156
+ ### F.3. `resume(toolCallId, payload)` for an unknown id
157
+ Silently no-ops. (The pre-class hook *threw*; the tracker softens this
158
+ to match the never-throw guarantee.)
159
+
160
+ ### F.4. Assistant-stream pipeline itself errors
161
+ The `.pipeTo(...).catch(...)` handler logs and flips `_pipelineDead`.
162
+ The next `setState` call recreates the pipeline once per tracker
163
+ lifetime: existing active entries are *demoted to restored* (so the
164
+ rebuilt pipeline does not re-fire `streamCall` for them) and the
165
+ snapshot is processed against the fresh pipeline. Repeated failures
166
+ keep the tracker dead with a visible error to avoid restart loops.
167
+
168
+ ## Known limitations
169
+
170
+ ### Result delivery after args regression (A.2 + A.5 in the same snapshot)
171
+ When a snapshot has both a regressed `argsText` *and* a backend result
172
+ on the same tool call, `activeController.setResponse(result)` closes
173
+ `argsText` before enqueueing the result chunk. The args-text-finish
174
+ chunk reaches `ToolExecutionStream` first, attempts to parse the
175
+ (stale) accumulated argsText, fails, and emits a parse-error result
176
+ that beats the backend result to the reader's response promise.
177
+
178
+ The tracker's `entry.hasResult` short-circuit *does* suppress both
179
+ result chunks at the `onResult` callback level (no double-fire), but
180
+ the reader's `response.get()` already resolved with the parse error.
181
+
182
+ Fixable upstream in `ToolCallStreamControllerImpl.setResponse` by
183
+ enqueueing the result chunk before closing argsText. Tracked separately;
184
+ out of scope for the tracker layer.
185
+
186
+ ### Host callback throws
187
+ `onResult` and `onStatusesChange` are invoked through wrappers that
188
+ catch and log. The tracker continues to function; the host's bad
189
+ callback is isolated.
190
+
191
+ ### Args-stream divergence after A.2 / A.4
192
+ Documented in the corresponding sections. The host's `streamCall` may
193
+ operate on stale args. The `reader.events()` follow-up gives consumers
194
+ a way to observe and react to these post-completion transitions.