@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.
- package/dist/index.d.ts +3 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/duplicate-detection.d.ts +5 -0
- package/dist/internal/duplicate-detection.d.ts.map +1 -0
- package/dist/internal/duplicate-detection.js +11 -0
- package/dist/internal/duplicate-detection.js.map +1 -0
- package/dist/react/AssistantProvider.d.ts.map +1 -1
- package/dist/react/AssistantProvider.js.map +1 -1
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +1 -2
- package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
- package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
- package/dist/react/runtimes/external-message-converter.d.ts +1 -1
- package/dist/react/runtimes/external-message-converter.js.map +1 -1
- package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
- package/dist/runtime/api/attachment-runtime.js.map +1 -1
- package/dist/runtime/interfaces/thread-runtime-core.d.ts +8 -0
- package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-adapter.d.ts +31 -0
- package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +25 -0
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js +94 -3
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts +168 -0
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -0
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +449 -0
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -0
- package/dist/subscribable/subscribable.d.ts.map +1 -1
- package/dist/subscribable/subscribable.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/index.ts +1 -4
- package/src/index.ts +10 -0
- package/src/internal/duplicate-detection.ts +26 -0
- package/src/react/AssistantProvider.tsx +2 -3
- package/src/react/index.ts +1 -6
- package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -2
- package/src/react/primitives/message/MessageAttachments.test.tsx +1 -1
- package/src/react/primitives/message/MessageGroupedParts.tsx +1 -1
- package/src/react/runtimes/external-message-converter.ts +1 -1
- package/src/runtime/api/attachment-runtime.ts +1 -2
- package/src/runtime/interfaces/thread-runtime-core.ts +8 -0
- package/src/runtime/internal.ts +1 -4
- package/src/runtimes/external-store/external-store-adapter.ts +33 -0
- package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +1 -3
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +161 -4
- package/src/runtimes/tool-invocations/EDGE_CASES.md +194 -0
- package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +1054 -0
- package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +783 -0
- package/src/subscribable/subscribable.ts +3 -3
- package/src/tests/OptimisticState-delete-crash.test.ts +2 -0
- package/src/tests/OptimisticState-list-race.test.ts +2 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +5 -5
- package/src/tests/auiV0Encode.test.ts +1 -1
- package/src/tests/composer-can-send.test.ts +8 -4
- package/src/tests/duplicate-detection.test.ts +34 -0
- package/src/tests/external-store-thread-list-runtime-core.test.ts +1 -1
- package/src/tests/external-store-thread-runtime-core.test.ts +7 -6
- package/src/tests/no-unsafe-process-env.test.ts +1 -0
- package/src/tests/remote-thread-list-isLoading.test.ts +2 -0
- package/src/tests/thread-message-like.test.ts +4 -1
- package/src/types/index.ts +1 -4
- package/dist/react/runtimes/useToolInvocations.d.ts +0 -53
- package/dist/react/runtimes/useToolInvocations.d.ts.map +0 -1
- package/dist/react/runtimes/useToolInvocations.js +0 -380
- package/dist/react/runtimes/useToolInvocations.js.map +0 -1
- 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
|
|
14
|
-
|
|
|
15
|
-
| undefined;
|
|
13
|
+
return (runtime as { _core?: AssistantRuntimeCore })._core
|
|
14
|
+
?.RenderComponent as ComponentType | undefined;
|
|
16
15
|
};
|
|
17
16
|
|
|
18
17
|
export type AssistantProviderBaseProps = PropsWithChildren<{
|
package/src/react/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 "
|
|
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 = {
|
package/src/runtime/internal.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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.
|