@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,783 @@
1
+ declare const process: { env: { NODE_ENV?: string } };
2
+
3
+ import {
4
+ createAssistantStreamController,
5
+ type ToolCallStreamController,
6
+ ToolResponse,
7
+ unstable_toolResultStream,
8
+ type Tool,
9
+ type ToolModelContentPart,
10
+ } from "assistant-stream";
11
+ import {
12
+ AssistantMetaTransformStream,
13
+ type ReadonlyJSONValue,
14
+ } from "assistant-stream/utils";
15
+ import { isJSONValueEqual } from "../../utils/json/is-json-equal";
16
+ import type { ThreadMessage } from "../../types/message";
17
+
18
+ /**
19
+ * Streaming execution state for a frontend tool.
20
+ */
21
+ export type ToolExecutionStatus =
22
+ | { type: "executing" }
23
+ | {
24
+ type: "interrupt";
25
+ payload: { type: "human"; payload: unknown };
26
+ };
27
+
28
+ export type AddToolResultCommand = {
29
+ readonly type: "add-tool-result";
30
+ readonly toolCallId: string;
31
+ readonly toolName: string;
32
+ readonly result: ReadonlyJSONValue;
33
+ readonly isError: boolean;
34
+ readonly artifact?: ReadonlyJSONValue;
35
+ readonly modelContent?: readonly ToolModelContentPart[];
36
+ };
37
+
38
+ export type ToolInvocationTrackerSnapshot = {
39
+ readonly messages: readonly ThreadMessage[];
40
+ /** Whether the producing runtime is currently streaming new output. */
41
+ readonly isRunning: boolean;
42
+ /**
43
+ * Whether the producing runtime is still loading historical state.
44
+ * When `true`, every snapshot is treated as historical (no `streamCall` /
45
+ * `execute` fires). When `false`, processing resumes as live.
46
+ */
47
+ readonly isLoading?: boolean;
48
+ };
49
+
50
+ export type ToolInvocationTrackerCallbacks = {
51
+ /**
52
+ * Invoked when a client-side `execute()` returns a result and the runtime
53
+ * needs to feed it back into the conversation.
54
+ */
55
+ onResult: (command: AddToolResultCommand) => void;
56
+ /**
57
+ * Invoked whenever the per-tool-call status map changes (executing /
58
+ * interrupt / cleared). The callback receives a fresh map; mutating the
59
+ * argument is not supported.
60
+ */
61
+ onStatusesChange: (
62
+ statuses: ReadonlyMap<string, ToolExecutionStatus>,
63
+ ) => void;
64
+ };
65
+
66
+ type ToolCallEntry = {
67
+ toolName: string;
68
+ argsText: string;
69
+ hasResult: boolean;
70
+ } & (
71
+ | {
72
+ /** Restored phase — observed during a history-load snapshot. */
73
+ controller?: undefined;
74
+ argsComplete?: undefined;
75
+ }
76
+ | {
77
+ /** Active phase — chunks are flowing through `controller`. */
78
+ controller: ToolCallStreamController;
79
+ argsComplete: boolean;
80
+ }
81
+ );
82
+
83
+ const isArgsTextComplete = (argsText: string) => {
84
+ try {
85
+ JSON.parse(argsText);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ };
91
+
92
+ const parseArgsText = (argsText: string) => {
93
+ try {
94
+ return JSON.parse(argsText);
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ };
99
+
100
+ const isEquivalentCompleteArgsText = (previous: string, next: string) => {
101
+ const previousValue = parseArgsText(previous);
102
+ const nextValue = parseArgsText(next);
103
+ if (previousValue === undefined || nextValue === undefined) return false;
104
+ return isJSONValueEqual(previousValue, nextValue);
105
+ };
106
+
107
+ /**
108
+ * Plain-class port of the former `useToolInvocations` React hook. Owns the
109
+ * assistant-stream pipeline that drives client-side `streamCall` / `execute`
110
+ * for tool-call parts surfaced by a thread runtime, plus the per-tool-call
111
+ * status map that consumers render against.
112
+ *
113
+ * **Contract**: `streamCall` (and `execute`) fires exactly once per logical
114
+ * `toolCallId`. Args mutations after first completion, result replacement,
115
+ * and result clearing are *not* surfaced through additional `streamCall`
116
+ * invocations — by design — so hosts cannot observe spurious re-fires of
117
+ * side effects. The follow-up `reader.events()` API will expose those
118
+ * post-completion transitions to consumers that opt in.
119
+ *
120
+ * State-transition safety: every public method that observes runtime state
121
+ * (`setState`, `reset`, `abort`, `resume`) wraps its work in try/catch and
122
+ * logs to `console.error` rather than throwing. The tracker is built into
123
+ * the hot message-processing path, so a malformed snapshot must never crash
124
+ * the host runtime. See ./EDGE_CASES.md for the known non-trivial state
125
+ * transitions and what each does today.
126
+ */
127
+ export class ToolInvocationTracker {
128
+ private readonly _getTools: () => Record<string, Tool> | undefined;
129
+ private readonly _callbacks: ToolInvocationTrackerCallbacks;
130
+
131
+ private readonly _entries = new Map<string, ToolCallEntry>();
132
+ /**
133
+ * Tool call ids whose `execute` should be short-circuited in the wrapper.
134
+ * Populated when an entry is created with a result already attached
135
+ * (history reload, mid-run resume, etc.) — `execute` is suppressed so
136
+ * client-side side effects don't double-run. Membership outlives the
137
+ * entry: `reset()` deliberately does *not* clear this so post-abort
138
+ * cancellation `result` chunks for pre-resolved entries can still be
139
+ * recognized and dropped. Growth is bounded by the number of pre-resolved
140
+ * tool calls observed in the session.
141
+ */
142
+ private readonly _skipExecuteStreamIds = new Set<string>();
143
+ private readonly _humanInput = new Map<
144
+ string,
145
+ {
146
+ resolve: (payload: unknown) => void;
147
+ reject: (reason: unknown) => void;
148
+ }
149
+ >();
150
+ /** In-flight `execute` invocations keyed by tool call id. */
151
+ private readonly _executing = new Set<string>();
152
+ private readonly _settledResolvers: Array<() => void> = [];
153
+
154
+ private _statuses = new Map<string, ToolExecutionStatus>();
155
+
156
+ private _ac: AbortController = new AbortController();
157
+ private _pendingRestore = true;
158
+
159
+ /** Cached last snapshot, used to skip processing on identical re-renders. */
160
+ private _lastSnapshot: ToolInvocationTrackerSnapshot | null = null;
161
+ private _isRunning = false;
162
+
163
+ private _controller!: ReturnType<typeof createAssistantStreamController>[1];
164
+
165
+ /**
166
+ * Set when the assistant-stream pipeline has died (errored out via
167
+ * `.pipeTo(...).catch(...)`). The next `setState` re-initializes the
168
+ * pipeline and demotes all active entries to restored so they survive
169
+ * across the restart without re-firing `streamCall` (preserves the
170
+ * "exactly once" contract). Capped at a single auto-restart per session
171
+ * — repeated failures keep the tracker dead with a more visible error.
172
+ */
173
+ private _pipelineDead = false;
174
+ private _pipelineRestartUsed = false;
175
+
176
+ constructor(
177
+ getTools: () => Record<string, Tool> | undefined,
178
+ callbacks: ToolInvocationTrackerCallbacks,
179
+ ) {
180
+ this._getTools = getTools;
181
+ this._callbacks = callbacks;
182
+
183
+ this._initPipeline();
184
+ }
185
+
186
+ /**
187
+ * Build the assistant-stream pipeline. Called once from the constructor
188
+ * and at most once again if `_pipelineDead` is set (see F.4 in
189
+ * EDGE_CASES.md).
190
+ */
191
+ private _initPipeline(): void {
192
+ const [stream, controller] = createAssistantStreamController();
193
+ this._controller = controller;
194
+
195
+ const transform = unstable_toolResultStream(
196
+ () => this._getWrappedTools(),
197
+ () => this._ac.signal,
198
+ (toolCallId, payload) => this._onHumanInput(toolCallId, payload),
199
+ {
200
+ onExecutionStart: (id) => this._onExecutionStart(id),
201
+ onExecutionEnd: (id) => this._onExecutionEnd(id),
202
+ },
203
+ );
204
+
205
+ stream
206
+ .pipeThrough(transform)
207
+ .pipeThrough(new AssistantMetaTransformStream())
208
+ .pipeTo(
209
+ new WritableStream({
210
+ write: (chunk) => {
211
+ try {
212
+ if (chunk.type !== "result") return;
213
+ this._handleResultChunk(chunk);
214
+ } catch (err) {
215
+ console.error(
216
+ "[ToolInvocationTracker] result chunk handling failed",
217
+ err,
218
+ );
219
+ }
220
+ },
221
+ }),
222
+ )
223
+ .catch((err) => {
224
+ console.error(
225
+ "[ToolInvocationTracker] stream pipeline failed; will attempt single restart on next setState",
226
+ err,
227
+ );
228
+ this._pipelineDead = true;
229
+ });
230
+ }
231
+
232
+ // ───────────────────────── public API ─────────────────────────
233
+
234
+ /**
235
+ * Feed the next observed snapshot into the tracker. Called from the host
236
+ * runtime whenever its message list / running state changes.
237
+ */
238
+ public setState(snapshot: ToolInvocationTrackerSnapshot): void {
239
+ try {
240
+ // Recover from a dead pipeline before processing anything. We demote
241
+ // all active entries to "restored" so the rebuilt pipeline does not
242
+ // re-fire `streamCall` for tool calls that already fired pre-death;
243
+ // preserves the "exactly once per toolCallId" contract.
244
+ if (this._pipelineDead) {
245
+ if (this._pipelineRestartUsed) {
246
+ // Already retried once and failed again. Stay dead.
247
+ return;
248
+ }
249
+ this._pipelineRestartUsed = true;
250
+ this._pipelineDead = false;
251
+ this._demoteEntriesToRestored();
252
+ this._executing.clear();
253
+ this._ac = new AbortController();
254
+ this._initPipeline();
255
+ // Fall through and process the snapshot against the fresh pipeline.
256
+ }
257
+
258
+ // Identical snapshot — skip processing entirely. Note: external-store
259
+ // runtimes rebuild the messages array on every adapter update, so this
260
+ // fast-path rarely triggers there; it's primarily for the React-hook
261
+ // shim where state references are stable.
262
+ if (
263
+ this._lastSnapshot &&
264
+ this._lastSnapshot.messages === snapshot.messages &&
265
+ this._lastSnapshot.isRunning === snapshot.isRunning &&
266
+ this._lastSnapshot.isLoading === snapshot.isLoading
267
+ ) {
268
+ return;
269
+ }
270
+
271
+ // While the host is still loading initial state, treat every snapshot
272
+ // as historical: tool calls are recorded so the next live snapshot can
273
+ // diff against them, but `streamCall` / `execute` do not fire.
274
+ const restoreFromLoading = snapshot.isLoading === true;
275
+ if (restoreFromLoading) {
276
+ this._pendingRestore = true;
277
+ }
278
+
279
+ // E.4 / AF3 — only mark `_lastSnapshot`/`_isRunning` as observed after
280
+ // processing succeeds. If `_processMessages` throws, the next snapshot
281
+ // (even if identical) gets re-processed against the recovered state.
282
+ const previousIsRunning = this._isRunning;
283
+ this._isRunning = snapshot.isRunning;
284
+ try {
285
+ this._processMessages(snapshot.messages);
286
+ } catch (err) {
287
+ this._isRunning = previousIsRunning;
288
+ throw err;
289
+ }
290
+ this._lastSnapshot = snapshot;
291
+ this._pendingRestore = false;
292
+ } catch (err) {
293
+ console.error(
294
+ "[ToolInvocationTracker] setState failed; snapshot dropped",
295
+ err,
296
+ );
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Reset the tracker so the next observed snapshot is treated as historical.
302
+ * Clears entries and aborts any in-flight executions. Used by callers like
303
+ * `importExternalState` to mark a freshly loaded state as restored.
304
+ */
305
+ public reset(): void {
306
+ try {
307
+ this._pendingRestore = true;
308
+ this._entries.clear();
309
+ this._lastSnapshot = null;
310
+ // `_skipExecuteStreamIds` is intentionally not cleared — see field doc.
311
+ void this.abort().finally(() => {
312
+ this._executing.clear();
313
+ });
314
+ } catch (err) {
315
+ console.error("[ToolInvocationTracker] reset failed", err);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Abort any in-flight `execute()` invocations. Resolves once all of them
321
+ * have settled (or immediately if none are running).
322
+ */
323
+ public abort(): Promise<void> {
324
+ try {
325
+ this._humanInput.forEach(({ reject }) => {
326
+ try {
327
+ reject(new Error("Tool execution aborted"));
328
+ } catch {
329
+ // host rejection handler threw — already in the abort path,
330
+ // swallow so we continue cleaning up.
331
+ }
332
+ });
333
+ this._humanInput.clear();
334
+
335
+ this._ac.abort();
336
+ this._ac = new AbortController();
337
+
338
+ if (this._executing.size === 0) {
339
+ return Promise.resolve();
340
+ }
341
+ return new Promise<void>((resolve) => {
342
+ this._settledResolvers.push(resolve);
343
+ });
344
+ } catch (err) {
345
+ console.error("[ToolInvocationTracker] abort failed", err);
346
+ return Promise.resolve();
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Resolve a pending human-input request for the given tool call. Returns
352
+ * `true` if a pending request was resumed, `false` if the tracker has no
353
+ * outstanding request for that id (the caller should fall back to its own
354
+ * dispatch path).
355
+ */
356
+ public resume(toolCallId: string, payload: unknown): boolean {
357
+ try {
358
+ const handlers = this._humanInput.get(toolCallId);
359
+ if (!handlers) return false;
360
+ this._humanInput.delete(toolCallId);
361
+ this._setStatus(toolCallId, { type: "executing" });
362
+ handlers.resolve(payload);
363
+ return true;
364
+ } catch (err) {
365
+ console.error("[ToolInvocationTracker] resume failed", err);
366
+ return false;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Returns the current tool execution status map. The returned `Map` is
372
+ * the tracker's internal store — do not mutate it. Treat the reference
373
+ * as a snapshot that may be replaced wholesale on the next status
374
+ * transition.
375
+ */
376
+ public getStatuses(): ReadonlyMap<string, ToolExecutionStatus> {
377
+ return this._statuses;
378
+ }
379
+
380
+ // ───────────────────── internal: tool wrapping ─────────────────────
381
+
382
+ private _getWrappedTools(): Record<string, Tool> | undefined {
383
+ const tools = this._getTools();
384
+ if (!tools) return undefined;
385
+
386
+ return Object.fromEntries(
387
+ Object.entries(tools).map(([name, tool]) => {
388
+ const execute = tool.execute;
389
+ if (execute === undefined) return [name, tool];
390
+
391
+ const wrappedTool = {
392
+ ...tool,
393
+ execute: (
394
+ ...[args, context]: Parameters<NonNullable<typeof execute>>
395
+ ) => {
396
+ if (this._skipExecuteStreamIds.has(context.toolCallId)) {
397
+ // Pre-resolved tool call: never invoke the host's execute.
398
+ // Returning a never-settling Promise keeps the executor's
399
+ // pending entry alive but enqueues nothing.
400
+ return new Promise(() => {}) as never;
401
+ }
402
+ return execute(args, context);
403
+ },
404
+ } as Tool;
405
+ return [name, wrappedTool];
406
+ }),
407
+ ) as Record<string, Tool>;
408
+ }
409
+
410
+ // ──────────────── internal: execution lifecycle callbacks ────────────────
411
+
412
+ private _onHumanInput(
413
+ toolCallId: string,
414
+ payload: unknown,
415
+ ): Promise<unknown> {
416
+ return new Promise<unknown>((resolve, reject) => {
417
+ const previous = this._humanInput.get(toolCallId);
418
+ if (previous) {
419
+ try {
420
+ previous.reject(
421
+ new Error("Human input request was superseded by a new request"),
422
+ );
423
+ } catch {
424
+ // host rejection handler threw; ignore and proceed
425
+ }
426
+ }
427
+ this._humanInput.set(toolCallId, { resolve, reject });
428
+ this._setStatus(toolCallId, {
429
+ type: "interrupt",
430
+ payload: { type: "human", payload },
431
+ });
432
+ });
433
+ }
434
+
435
+ private _onExecutionStart(toolCallId: string): void {
436
+ if (this._skipExecuteStreamIds.has(toolCallId)) return;
437
+
438
+ this._executing.add(toolCallId);
439
+ this._setStatus(toolCallId, { type: "executing" });
440
+ }
441
+
442
+ private _onExecutionEnd(toolCallId: string): void {
443
+ if (!this._executing.delete(toolCallId)) return;
444
+
445
+ this._deleteStatus(toolCallId);
446
+
447
+ if (this._executing.size === 0) {
448
+ const resolvers = this._settledResolvers.splice(0);
449
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
450
+ resolvers.forEach((resolve) => {
451
+ try {
452
+ resolve();
453
+ } catch {
454
+ // ignore — settled-resolver consumer threw
455
+ }
456
+ });
457
+ }
458
+ }
459
+
460
+ private _handleResultChunk(chunk: {
461
+ type: "result";
462
+ result: ReadonlyJSONValue;
463
+ isError: boolean;
464
+ artifact?: ReadonlyJSONValue;
465
+ modelContent?: readonly ToolModelContentPart[];
466
+ meta: { toolCallId: string; toolName: string };
467
+ }): void {
468
+ const toolCallId = chunk.meta.toolCallId;
469
+ const entry = this._entries.get(toolCallId);
470
+
471
+ // Pre-resolved tool call whose entry has been cleared by `reset()`.
472
+ // The post-abort cancellation chunk lands here after the entry is
473
+ // gone; suppress via the long-lived skip-execute marker.
474
+ if (!entry && this._skipExecuteStreamIds.has(toolCallId)) {
475
+ return;
476
+ }
477
+
478
+ // The host already set the result (via the live snapshot's
479
+ // `setResponse` path). Suppress the executor's redundant emit.
480
+ if (entry?.hasResult) return;
481
+
482
+ this._invokeOnResult({
483
+ type: "add-tool-result",
484
+ toolCallId,
485
+ toolName: chunk.meta.toolName,
486
+ result: chunk.result,
487
+ isError: chunk.isError,
488
+ ...(chunk.artifact !== undefined && { artifact: chunk.artifact }),
489
+ ...(chunk.modelContent !== undefined && {
490
+ modelContent: chunk.modelContent,
491
+ }),
492
+ });
493
+ }
494
+
495
+ // ──────────────── internal: callback invocation (AF1/AF2) ────────────────
496
+
497
+ private _invokeOnResult(command: AddToolResultCommand): void {
498
+ try {
499
+ this._callbacks.onResult(command);
500
+ } catch (err) {
501
+ console.error(
502
+ "[ToolInvocationTracker] onResult callback threw; result dropped",
503
+ err,
504
+ );
505
+ }
506
+ }
507
+
508
+ private _invokeOnStatusesChange(): void {
509
+ try {
510
+ this._callbacks.onStatusesChange(this._statuses);
511
+ } catch (err) {
512
+ console.error(
513
+ "[ToolInvocationTracker] onStatusesChange callback threw; status change not propagated",
514
+ err,
515
+ );
516
+ }
517
+ }
518
+
519
+ // ──────────────── internal: status map mutations ────────────────
520
+
521
+ private _setStatus(toolCallId: string, status: ToolExecutionStatus): void {
522
+ const next = new Map(this._statuses);
523
+ next.set(toolCallId, status);
524
+ this._statuses = next;
525
+ this._invokeOnStatusesChange();
526
+ }
527
+
528
+ private _deleteStatus(toolCallId: string): void {
529
+ if (!this._statuses.has(toolCallId)) return;
530
+ const next = new Map(this._statuses);
531
+ next.delete(toolCallId);
532
+ this._statuses = next;
533
+ this._invokeOnStatusesChange();
534
+ }
535
+
536
+ // ──────────────── internal: snapshot processing ────────────────
537
+
538
+ private _hasExecutableTool(toolName: string): boolean {
539
+ const tool = this._getTools()?.[toolName];
540
+ return tool?.execute !== undefined || tool?.streamCall !== undefined;
541
+ }
542
+
543
+ private _shouldCloseArgsStream({
544
+ toolName,
545
+ argsText,
546
+ hasResult,
547
+ }: {
548
+ toolName: string;
549
+ argsText: string;
550
+ hasResult: boolean;
551
+ }): boolean {
552
+ if (hasResult) return true;
553
+ if (!this._hasExecutableTool(toolName)) {
554
+ return !this._isRunning && isArgsTextComplete(argsText);
555
+ }
556
+ return isArgsTextComplete(argsText);
557
+ }
558
+
559
+ private _startActiveEntry(
560
+ toolCallId: string,
561
+ toolName: string,
562
+ skipExecute: boolean,
563
+ ): ToolCallEntry {
564
+ const toolCallController = this._controller.addToolCallPart({
565
+ toolName,
566
+ toolCallId,
567
+ });
568
+ if (skipExecute) {
569
+ this._skipExecuteStreamIds.add(toolCallId);
570
+ }
571
+ const entry: ToolCallEntry = {
572
+ toolName,
573
+ controller: toolCallController,
574
+ argsText: "",
575
+ hasResult: false,
576
+ argsComplete: false,
577
+ };
578
+ this._entries.set(toolCallId, entry);
579
+ return entry;
580
+ }
581
+
582
+ /**
583
+ * Demote every active entry back to the restored phase. Used by the
584
+ * pipeline-restart path so that, after a fresh pipeline is built, the
585
+ * next observed snapshot does not re-fire `streamCall` for tool calls
586
+ * that already fired pre-death. Args / hasResult tracking is preserved
587
+ * so signature comparisons still work.
588
+ */
589
+ private _demoteEntriesToRestored(): void {
590
+ for (const [toolCallId, entry] of this._entries) {
591
+ if (!entry.controller) continue;
592
+ this._entries.set(toolCallId, {
593
+ toolName: entry.toolName,
594
+ argsText: entry.argsText,
595
+ hasResult: entry.hasResult,
596
+ });
597
+ }
598
+ }
599
+
600
+ private _processArgsText(
601
+ entry: ToolCallEntry,
602
+ content: {
603
+ toolCallId: string;
604
+ toolName: string;
605
+ argsText: string;
606
+ result?: unknown;
607
+ },
608
+ ): void {
609
+ if (!entry.controller) return;
610
+ const hasResult = content.result !== undefined;
611
+
612
+ if (content.argsText !== entry.argsText) {
613
+ let shouldWriteArgsText = true;
614
+
615
+ if (entry.argsComplete) {
616
+ if (isEquivalentCompleteArgsText(entry.argsText, content.argsText)) {
617
+ // A.3 — key reorder. Track new text, no re-fire needed.
618
+ entry.argsText = content.argsText;
619
+ shouldWriteArgsText = false;
620
+ } else {
621
+ // A.4 — args changed after first completion. Under the
622
+ // "exactly once per toolCallId" contract we do not restart the
623
+ // stream. The host's existing `streamCall` keeps its original
624
+ // args view; the snapshot's new text is recorded for diffing
625
+ // but not surfaced. Events API in a follow-up will expose this
626
+ // to consumers that opt in.
627
+ if (process.env.NODE_ENV !== "production") {
628
+ console.warn(
629
+ "[ToolInvocationTracker] argsText changed after first completion; not re-firing streamCall (see EDGE_CASES.md A.4)",
630
+ {
631
+ previous: entry.argsText,
632
+ next: content.argsText,
633
+ toolCallId: content.toolCallId,
634
+ },
635
+ );
636
+ }
637
+ shouldWriteArgsText = false;
638
+ }
639
+ } else if (!content.argsText.startsWith(entry.argsText)) {
640
+ if (
641
+ isArgsTextComplete(entry.argsText) &&
642
+ isArgsTextComplete(content.argsText) &&
643
+ isEquivalentCompleteArgsText(entry.argsText, content.argsText)
644
+ ) {
645
+ const shouldClose = this._shouldCloseArgsStream({
646
+ toolName: content.toolName,
647
+ argsText: content.argsText,
648
+ hasResult,
649
+ });
650
+ if (shouldClose) entry.controller.argsText.close();
651
+ entry.argsText = content.argsText;
652
+ entry.argsComplete = shouldClose;
653
+ shouldWriteArgsText = false;
654
+ } else {
655
+ // A.2 — args regressed mid-stream. Under the "exactly once"
656
+ // contract we do not restart. The controller keeps whatever
657
+ // prefix we already streamed; subsequent prefix-respecting
658
+ // updates can still flow against it. Snapshots that never
659
+ // re-converge to a prefix will leave the controller's args
660
+ // view stale relative to the snapshot. Events API in a
661
+ // follow-up will expose this to consumers that opt in.
662
+ if (process.env.NODE_ENV !== "production") {
663
+ console.warn(
664
+ "[ToolInvocationTracker] argsText regressed mid-stream; not restarting (see EDGE_CASES.md A.2)",
665
+ {
666
+ previous: entry.argsText,
667
+ next: content.argsText,
668
+ toolCallId: content.toolCallId,
669
+ },
670
+ );
671
+ }
672
+ shouldWriteArgsText = false;
673
+ }
674
+ }
675
+
676
+ if (shouldWriteArgsText && entry.controller) {
677
+ const delta = content.argsText.slice(entry.argsText.length);
678
+ entry.controller.argsText.append(delta);
679
+ const shouldClose = this._shouldCloseArgsStream({
680
+ toolName: content.toolName,
681
+ argsText: content.argsText,
682
+ hasResult,
683
+ });
684
+ if (shouldClose) entry.controller.argsText.close();
685
+ entry.argsText = content.argsText;
686
+ entry.argsComplete = shouldClose;
687
+ }
688
+ }
689
+
690
+ if (!entry.argsComplete && entry.controller) {
691
+ const shouldClose = this._shouldCloseArgsStream({
692
+ toolName: content.toolName,
693
+ argsText: content.argsText,
694
+ hasResult,
695
+ });
696
+ if (shouldClose) {
697
+ entry.controller.argsText.close();
698
+ entry.argsText = content.argsText;
699
+ entry.argsComplete = true;
700
+ }
701
+ }
702
+ }
703
+
704
+ private _processMessages(messages: readonly ThreadMessage[]): void {
705
+ const isRestore = this._pendingRestore;
706
+
707
+ for (const message of messages) {
708
+ if (!message || !Array.isArray((message as ThreadMessage).content)) {
709
+ continue;
710
+ }
711
+ for (const content of message.content as readonly ThreadMessage["content"][number][]) {
712
+ if (!content || content.type !== "tool-call") continue;
713
+
714
+ const existing = this._entries.get(content.toolCallId);
715
+
716
+ if (isRestore) {
717
+ // Don't overwrite an already-active entry (e.g. live tool-call
718
+ // observed before this restore snapshot landed). Restore can
719
+ // only seed entries the runtime has never seen.
720
+ if (!existing?.controller) {
721
+ this._entries.set(content.toolCallId, {
722
+ toolName: content.toolName,
723
+ argsText: content.argsText,
724
+ hasResult: content.result !== undefined,
725
+ });
726
+ }
727
+ if (content.messages) this._processMessages(content.messages);
728
+ continue;
729
+ }
730
+
731
+ // Live snapshot.
732
+ let entry = existing;
733
+
734
+ if (entry && !entry.controller) {
735
+ // Restored entry observed in a live snapshot. Promote if its
736
+ // signature has changed; otherwise treat as still-historical.
737
+ const signatureChanged =
738
+ content.argsText !== entry.argsText ||
739
+ (content.result !== undefined) !== entry.hasResult;
740
+ if (!signatureChanged) {
741
+ if (content.messages) this._processMessages(content.messages);
742
+ continue;
743
+ }
744
+ this._entries.delete(content.toolCallId);
745
+ entry = undefined;
746
+ }
747
+
748
+ if (!entry) {
749
+ entry = this._startActiveEntry(
750
+ content.toolCallId,
751
+ content.toolName,
752
+ content.result !== undefined,
753
+ );
754
+ }
755
+
756
+ this._processArgsText(entry, content);
757
+
758
+ if (content.result !== undefined && !entry.hasResult) {
759
+ // `entry` is in active phase from this point — either just
760
+ // created by `_startActiveEntry`, or pre-existing with a live
761
+ // controller. Narrow once instead of asserting at every use.
762
+ const { controller: activeController } = entry;
763
+ if (!activeController) continue;
764
+ entry.hasResult = true;
765
+ entry.argsComplete = true;
766
+ activeController.setResponse(
767
+ new ToolResponse({
768
+ result: content.result as ReadonlyJSONValue,
769
+ artifact: content.artifact as ReadonlyJSONValue | undefined,
770
+ isError: content.isError,
771
+ ...(content.modelContent !== undefined
772
+ ? { modelContent: content.modelContent }
773
+ : {}),
774
+ }),
775
+ );
776
+ activeController.close();
777
+ }
778
+
779
+ if (content.messages) this._processMessages(content.messages);
780
+ }
781
+ }
782
+ }
783
+ }