@assistant-ui/core 0.2.6 → 0.2.8
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/adapters/attachment.d.ts.map +1 -1
- package/dist/adapters/speech.d.ts.map +1 -1
- package/dist/adapters/speech.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +8 -1
- 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/internal.d.ts +2 -2
- package/dist/internal.js +2 -2
- package/dist/model-context/frame/host.d.ts.map +1 -1
- package/dist/model-context/frame/host.js.map +1 -1
- package/dist/model-context/frame/provider.d.ts.map +1 -1
- package/dist/model-context/frame/provider.js.map +1 -1
- package/dist/model-context/registry.d.ts.map +1 -1
- package/dist/model-context/tool.d.ts.map +1 -1
- package/dist/react/AssistantProvider.d.ts.map +1 -1
- package/dist/react/AssistantProvider.js.map +1 -1
- package/dist/react/client/Interactables.js.map +1 -1
- package/dist/react/client/Tools.d.ts.map +1 -1
- package/dist/react/client/Tools.js +26 -15
- package/dist/react/client/Tools.js.map +1 -1
- package/dist/react/index.d.ts +5 -4
- package/dist/react/index.js +2 -2
- package/dist/react/model-context/toolbox.d.ts +29 -2
- package/dist/react/model-context/toolbox.d.ts.map +1 -1
- package/dist/react/model-context/toolbox.js +18 -0
- package/dist/react/model-context/toolbox.js.map +1 -0
- package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
- package/dist/react/model-context/useAssistantTool.js +6 -3
- package/dist/react/model-context/useAssistantTool.js.map +1 -1
- package/dist/react/model-context/useAssistantToolUI.d.ts +6 -0
- package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
- package/dist/react/model-context/useAssistantToolUI.js +4 -2
- package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
- package/dist/react/model-context/useInlineRender.js.map +1 -1
- package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
- package/dist/react/primitives/message/MessageGroupedParts.d.ts +49 -7
- package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageGroupedParts.js +28 -3
- package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
- package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageParts.js +2 -7
- package/dist/react/primitives/message/MessageParts.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/RuntimeAdapterProvider.d.ts.map +1 -1
- package/dist/react/runtimes/RuntimeAdapterProvider.js +6 -5
- package/dist/react/runtimes/RuntimeAdapterProvider.js.map +1 -1
- package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
- package/dist/react/runtimes/external-message-converter.d.ts +1 -1
- package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
- package/dist/react/runtimes/external-message-converter.js +1 -0
- package/dist/react/runtimes/external-message-converter.js.map +1 -1
- package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts +7 -0
- package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts.map +1 -0
- package/dist/react/runtimes/useExternalStoreSharedOptions.js +21 -0
- package/dist/react/runtimes/useExternalStoreSharedOptions.js.map +1 -0
- package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
- package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
- package/dist/react/types/scopes/tools.d.ts +19 -2
- package/dist/react/types/scopes/tools.d.ts.map +1 -1
- package/dist/react/utils/groupParts.d.ts +32 -11
- package/dist/react/utils/groupParts.d.ts.map +1 -1
- package/dist/react/utils/groupParts.js +13 -6
- package/dist/react/utils/groupParts.js.map +1 -1
- package/dist/runtime/api/assistant-runtime.d.ts.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/api/composer-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/base/base-assistant-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.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/runtime/utils/message-repository.d.ts +9 -1
- package/dist/runtime/utils/message-repository.d.ts.map +1 -1
- package/dist/runtime/utils/message-repository.js +34 -14
- package/dist/runtime/utils/message-repository.js.map +1 -1
- package/dist/runtime/utils/thread-message-like.d.ts +1 -0
- package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
- package/dist/runtime/utils/thread-message-like.js +2 -1
- package/dist/runtime/utils/thread-message-like.js.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-shared-options.d.ts +8 -0
- package/dist/runtimes/external-store/external-store-shared-options.d.ts.map +1 -0
- package/dist/runtimes/external-store/external-store-shared-options.js +11 -0
- package/dist/runtimes/external-store/external-store-shared-options.js.map +1 -0
- 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 -2
- 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 +106 -26
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/external-store/thread-message-converter.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/adapter/in-memory.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.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/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
- package/dist/types/message.d.ts +6 -0
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/message.js.map +1 -1
- package/dist/utils/composite-context-provider.d.ts.map +1 -1
- package/dist/utils/id.d.ts +1 -3
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +1 -4
- package/dist/utils/id.js.map +1 -1
- package/package.json +10 -10
- package/src/adapters/index.ts +1 -4
- package/src/adapters/speech.ts +0 -1
- package/src/index.ts +12 -0
- package/src/internal/duplicate-detection.ts +26 -0
- package/src/internal.ts +0 -2
- package/src/model-context/frame/host.ts +0 -1
- package/src/model-context/frame/provider.ts +0 -1
- package/src/react/AssistantProvider.tsx +2 -3
- package/src/react/client/Interactables.ts +0 -1
- package/src/react/client/Tools.ts +50 -25
- package/src/react/index.ts +9 -8
- package/src/react/model-context/toolbox.ts +46 -1
- package/src/react/model-context/useAssistantTool.ts +8 -3
- package/src/react/model-context/useAssistantToolUI.ts +9 -2
- package/src/react/model-context/useInlineRender.ts +0 -1
- 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 +102 -13
- package/src/react/primitives/message/MessageParts.tsx +4 -7
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +0 -3
- package/src/react/runtimes/RuntimeAdapterProvider.tsx +12 -7
- package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +0 -3
- package/src/react/runtimes/external-message-converter.ts +5 -1
- package/src/react/runtimes/useExternalStoreSharedOptions.ts +23 -0
- package/src/react/runtimes/useLocalRuntime.ts +0 -10
- package/src/react/runtimes/useRemoteThreadListRuntime.ts +0 -6
- package/src/react/types/scopes/tools.ts +20 -1
- package/src/react/utils/groupParts.ts +49 -18
- 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/runtime/utils/message-repository.ts +57 -16
- package/src/runtime/utils/thread-message-like.ts +2 -0
- package/src/runtimes/external-store/external-store-adapter.ts +33 -0
- package/src/runtimes/external-store/external-store-shared-options.ts +18 -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 +179 -37
- 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 +782 -0
- package/src/subscribable/subscribable.ts +3 -3
- package/src/tests/MessageRepository.test.ts +83 -52
- package/src/tests/OptimisticState-delete-crash.test.ts +2 -0
- package/src/tests/OptimisticState-list-race.test.ts +2 -4
- 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 +112 -79
- package/src/tests/groupParts.test.ts +70 -0
- package/src/tests/no-unsafe-process-env.test.ts +1 -0
- package/src/tests/remote-thread-list-isLoading.test.ts +2 -5
- package/src/tests/thread-message-like.test.ts +4 -1
- package/src/types/index.ts +1 -4
- package/src/types/message.ts +6 -0
- package/src/utils/id.ts +0 -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,1054 @@
|
|
|
1
|
+
import type { Tool } from "assistant-stream";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
ToolInvocationTracker,
|
|
5
|
+
type ToolExecutionStatus,
|
|
6
|
+
type ToolInvocationTrackerSnapshot,
|
|
7
|
+
} from "./ToolInvocationTracker";
|
|
8
|
+
import type {
|
|
9
|
+
ThreadAssistantMessage,
|
|
10
|
+
ThreadMessage,
|
|
11
|
+
} from "../../types/message";
|
|
12
|
+
import type {
|
|
13
|
+
ReadonlyJSONObject,
|
|
14
|
+
ReadonlyJSONValue,
|
|
15
|
+
} from "assistant-stream/utils";
|
|
16
|
+
|
|
17
|
+
async function waitFor(
|
|
18
|
+
predicate: () => unknown,
|
|
19
|
+
timeoutMs = 500,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
while (Date.now() - start < timeoutMs) {
|
|
23
|
+
try {
|
|
24
|
+
await predicate();
|
|
25
|
+
return;
|
|
26
|
+
} catch {
|
|
27
|
+
// retry
|
|
28
|
+
}
|
|
29
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
30
|
+
}
|
|
31
|
+
await predicate();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const createState = (
|
|
35
|
+
messages: ThreadAssistantMessage[],
|
|
36
|
+
isRunning: boolean = true,
|
|
37
|
+
): ToolInvocationTrackerSnapshot => ({
|
|
38
|
+
messages: messages as readonly ThreadMessage[],
|
|
39
|
+
isRunning,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const createAssistantMessage = (
|
|
43
|
+
argsText: string,
|
|
44
|
+
args: Record<string, unknown>,
|
|
45
|
+
options?: {
|
|
46
|
+
result?: ReadonlyJSONValue;
|
|
47
|
+
isError?: boolean;
|
|
48
|
+
toolCallId?: string;
|
|
49
|
+
toolName?: string;
|
|
50
|
+
nestedMessages?: ThreadAssistantMessage[];
|
|
51
|
+
},
|
|
52
|
+
): ThreadAssistantMessage => ({
|
|
53
|
+
id: "m-1",
|
|
54
|
+
role: "assistant",
|
|
55
|
+
createdAt: new Date(),
|
|
56
|
+
status: { type: "requires-action", reason: "tool-calls" },
|
|
57
|
+
metadata: {
|
|
58
|
+
unstable_state: null,
|
|
59
|
+
unstable_annotations: [],
|
|
60
|
+
unstable_data: [],
|
|
61
|
+
steps: [],
|
|
62
|
+
custom: {},
|
|
63
|
+
},
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: "tool-call",
|
|
67
|
+
toolCallId: options?.toolCallId ?? "tool-1",
|
|
68
|
+
toolName: options?.toolName ?? "weatherSearch",
|
|
69
|
+
args: args as ReadonlyJSONObject,
|
|
70
|
+
argsText,
|
|
71
|
+
...(options?.result !== undefined && { result: options.result }),
|
|
72
|
+
...(options?.isError !== undefined && { isError: options.isError }),
|
|
73
|
+
...(options?.nestedMessages && { messages: options.nestedMessages }),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("ToolInvocationTracker", () => {
|
|
79
|
+
it("does not crash and does not re-fire streamCall when tool argsText regresses mid-stream", async () => {
|
|
80
|
+
// The tracker holds the contract: streamCall fires exactly once per
|
|
81
|
+
// logical toolCallId, no matter how the host's argsText mutates. Under
|
|
82
|
+
// the legacy restart behavior, this scenario caused a second streamCall
|
|
83
|
+
// / execute invocation against a synthetic rewrite stream id. With the
|
|
84
|
+
// new contract, the controller keeps whatever prefix already streamed;
|
|
85
|
+
// a regressed (non-prefix) argsText is observed but not surfaced through
|
|
86
|
+
// a re-invocation. EDGE_CASES.md A.2 captures the trade-off; the events
|
|
87
|
+
// API follow-up will expose the divergence to consumers that opt in.
|
|
88
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
89
|
+
const streamCall = vi.fn();
|
|
90
|
+
const getTools = () => ({
|
|
91
|
+
weatherSearch: {
|
|
92
|
+
parameters: { type: "object", properties: {} },
|
|
93
|
+
execute,
|
|
94
|
+
streamCall,
|
|
95
|
+
} satisfies Tool,
|
|
96
|
+
});
|
|
97
|
+
const onResult = vi.fn();
|
|
98
|
+
const onStatusesChange = () => {};
|
|
99
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
103
|
+
onResult,
|
|
104
|
+
onStatusesChange,
|
|
105
|
+
});
|
|
106
|
+
tracker.setState(createState([]));
|
|
107
|
+
|
|
108
|
+
expect(() => {
|
|
109
|
+
tracker.setState(
|
|
110
|
+
createState([
|
|
111
|
+
createAssistantMessage('{"query":"London","longitude":0', {
|
|
112
|
+
query: "London",
|
|
113
|
+
longitude: 0,
|
|
114
|
+
}),
|
|
115
|
+
]),
|
|
116
|
+
);
|
|
117
|
+
}).not.toThrow();
|
|
118
|
+
|
|
119
|
+
expect(() => {
|
|
120
|
+
tracker.setState(
|
|
121
|
+
createState([
|
|
122
|
+
createAssistantMessage('{"query":"London","longitude":-0.125', {
|
|
123
|
+
query: "London",
|
|
124
|
+
longitude: -0.125,
|
|
125
|
+
}),
|
|
126
|
+
]),
|
|
127
|
+
);
|
|
128
|
+
}).not.toThrow();
|
|
129
|
+
|
|
130
|
+
tracker.setState(
|
|
131
|
+
createState([
|
|
132
|
+
createAssistantMessage(
|
|
133
|
+
'{"query":"London","longitude":-0.125,"latitude":51.5072}',
|
|
134
|
+
{ query: "London", longitude: -0.125, latitude: 51.5072 },
|
|
135
|
+
),
|
|
136
|
+
]),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Exactly-once contract: streamCall fired once (on first observation),
|
|
140
|
+
// no rewrite or re-fire despite two subsequent non-prefix regressions.
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The regression was detected and logged (non-prod only).
|
|
146
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
147
|
+
expect.stringContaining("regressed mid-stream"),
|
|
148
|
+
expect.objectContaining({ toolCallId: "tool-1" }),
|
|
149
|
+
);
|
|
150
|
+
} finally {
|
|
151
|
+
warnSpy.mockRestore();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("clears executing status under the logical toolCallId when reset() lands while execute is pending", async () => {
|
|
156
|
+
// Tests the F.1 lifecycle: reset() aborts in-flight execute() invocations
|
|
157
|
+
// and clears their executing status. The status key is the logical
|
|
158
|
+
// toolCallId (no synthetic stream ids exist under the new
|
|
159
|
+
// exactly-once-per-toolCallId contract).
|
|
160
|
+
const execute = vi.fn(
|
|
161
|
+
async () =>
|
|
162
|
+
await new Promise(() => {
|
|
163
|
+
// never resolves: reset() should cancel this call
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
const getTools = () => ({
|
|
167
|
+
weatherSearch: {
|
|
168
|
+
parameters: { type: "object", properties: {} },
|
|
169
|
+
execute,
|
|
170
|
+
} satisfies Tool,
|
|
171
|
+
});
|
|
172
|
+
const onResult = vi.fn();
|
|
173
|
+
|
|
174
|
+
let statuses: Record<string, ToolExecutionStatus> = {};
|
|
175
|
+
const onStatusesChange = (s: ReadonlyMap<string, ToolExecutionStatus>) => {
|
|
176
|
+
statuses = Object.fromEntries(s);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
180
|
+
onResult,
|
|
181
|
+
onStatusesChange,
|
|
182
|
+
});
|
|
183
|
+
tracker.setState(createState([]));
|
|
184
|
+
|
|
185
|
+
// Single monotonic snapshot growing to a complete value triggers execute.
|
|
186
|
+
tracker.setState(
|
|
187
|
+
createState([
|
|
188
|
+
createAssistantMessage('{"query":"London"', { query: "London" }),
|
|
189
|
+
]),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
tracker.setState(
|
|
193
|
+
createState([
|
|
194
|
+
createAssistantMessage('{"query":"London"}', { query: "London" }),
|
|
195
|
+
]),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
200
|
+
});
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(statuses["tool-1"]).toEqual({ type: "executing" });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
tracker.reset();
|
|
206
|
+
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(statuses).toEqual({});
|
|
209
|
+
});
|
|
210
|
+
// No legacy `:rewrite:N` stream ids leak into the status map.
|
|
211
|
+
expect(Object.keys(statuses).some((k) => k.includes(":rewrite:"))).toBe(
|
|
212
|
+
false,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not execute tool calls loaded asynchronously with existing results", async () => {
|
|
217
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
218
|
+
const getTools = () => ({
|
|
219
|
+
weatherSearch: {
|
|
220
|
+
parameters: { type: "object", properties: {} },
|
|
221
|
+
execute,
|
|
222
|
+
} satisfies Tool,
|
|
223
|
+
});
|
|
224
|
+
const onResult = vi.fn();
|
|
225
|
+
const onStatusesChange = () => {};
|
|
226
|
+
|
|
227
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
228
|
+
onResult,
|
|
229
|
+
onStatusesChange,
|
|
230
|
+
});
|
|
231
|
+
tracker.setState(createState([]));
|
|
232
|
+
|
|
233
|
+
tracker.setState(
|
|
234
|
+
createState([
|
|
235
|
+
createAssistantMessage(
|
|
236
|
+
'{"query":"London"}',
|
|
237
|
+
{ query: "London" },
|
|
238
|
+
{ result: { source: "history" } },
|
|
239
|
+
),
|
|
240
|
+
]),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
await waitFor(() => {
|
|
244
|
+
expect(execute).not.toHaveBeenCalled();
|
|
245
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("does not re-execute asynchronously loaded resolved tool calls after reset", async () => {
|
|
250
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
251
|
+
const getTools = () => ({
|
|
252
|
+
weatherSearch: {
|
|
253
|
+
parameters: { type: "object", properties: {} },
|
|
254
|
+
execute,
|
|
255
|
+
} satisfies Tool,
|
|
256
|
+
});
|
|
257
|
+
const onResult = vi.fn();
|
|
258
|
+
const onStatusesChange = () => {};
|
|
259
|
+
|
|
260
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
261
|
+
onResult,
|
|
262
|
+
onStatusesChange,
|
|
263
|
+
});
|
|
264
|
+
tracker.setState(createState([]));
|
|
265
|
+
|
|
266
|
+
tracker.setState(
|
|
267
|
+
createState([
|
|
268
|
+
createAssistantMessage('{"query":"London"}', { query: "London" }),
|
|
269
|
+
]),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
await waitFor(() => {
|
|
273
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
tracker.reset();
|
|
277
|
+
|
|
278
|
+
await Promise.resolve();
|
|
279
|
+
|
|
280
|
+
tracker.setState(createState([]));
|
|
281
|
+
|
|
282
|
+
tracker.setState(
|
|
283
|
+
createState([
|
|
284
|
+
createAssistantMessage(
|
|
285
|
+
'{"query":"London"}',
|
|
286
|
+
{ query: "London" },
|
|
287
|
+
{ result: { source: "history" } },
|
|
288
|
+
),
|
|
289
|
+
]),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
await waitFor(() => {
|
|
293
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(onResult).toHaveBeenCalledTimes(1);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("still processes nested unresolved tool calls when the parent tool call is already resolved", async () => {
|
|
299
|
+
const executeParent = vi.fn(async () => ({ scope: "parent" }));
|
|
300
|
+
const executeChild = vi.fn(async () => ({ scope: "child" }));
|
|
301
|
+
const getTools = () => ({
|
|
302
|
+
resolvedOnly: {
|
|
303
|
+
parameters: { type: "object", properties: {} },
|
|
304
|
+
execute: executeParent,
|
|
305
|
+
} satisfies Tool,
|
|
306
|
+
childTool: {
|
|
307
|
+
parameters: { type: "object", properties: {} },
|
|
308
|
+
execute: executeChild,
|
|
309
|
+
} satisfies Tool,
|
|
310
|
+
});
|
|
311
|
+
const onResult = vi.fn();
|
|
312
|
+
const onStatusesChange = () => {};
|
|
313
|
+
|
|
314
|
+
const nestedMessage = createAssistantMessage(
|
|
315
|
+
'{"query":"nested"}',
|
|
316
|
+
{ query: "nested" },
|
|
317
|
+
{
|
|
318
|
+
toolCallId: "tool-child",
|
|
319
|
+
toolName: "childTool",
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
324
|
+
onResult,
|
|
325
|
+
onStatusesChange,
|
|
326
|
+
});
|
|
327
|
+
tracker.setState(createState([]));
|
|
328
|
+
|
|
329
|
+
tracker.setState(
|
|
330
|
+
createState([
|
|
331
|
+
createAssistantMessage(
|
|
332
|
+
'{"query":"parent"}',
|
|
333
|
+
{ query: "parent" },
|
|
334
|
+
{
|
|
335
|
+
result: { source: "history" },
|
|
336
|
+
toolName: "resolvedOnly",
|
|
337
|
+
nestedMessages: [nestedMessage],
|
|
338
|
+
},
|
|
339
|
+
),
|
|
340
|
+
]),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
await waitFor(() => {
|
|
344
|
+
expect(executeParent).not.toHaveBeenCalled();
|
|
345
|
+
expect(executeChild).toHaveBeenCalledTimes(1);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("does not close args stream early for non-executable tool snapshots", () => {
|
|
350
|
+
const getTools = () => ({
|
|
351
|
+
weatherSearch: {
|
|
352
|
+
parameters: { type: "object", properties: {} },
|
|
353
|
+
} satisfies Tool,
|
|
354
|
+
});
|
|
355
|
+
const onResult = vi.fn();
|
|
356
|
+
const onStatusesChange = () => {};
|
|
357
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
361
|
+
onResult,
|
|
362
|
+
onStatusesChange,
|
|
363
|
+
});
|
|
364
|
+
tracker.setState(createState([]));
|
|
365
|
+
|
|
366
|
+
tracker.setState(createState([createAssistantMessage("{}", {})]));
|
|
367
|
+
|
|
368
|
+
tracker.setState(
|
|
369
|
+
createState([
|
|
370
|
+
createAssistantMessage('{"title":"Weekly"', {
|
|
371
|
+
title: "Weekly",
|
|
372
|
+
}),
|
|
373
|
+
]),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
tracker.setState(
|
|
377
|
+
createState([
|
|
378
|
+
createAssistantMessage('{"title":"Weekly","columns":["name"]}', {
|
|
379
|
+
title: "Weekly",
|
|
380
|
+
columns: ["name"],
|
|
381
|
+
}),
|
|
382
|
+
]),
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
386
|
+
"argsText updated after controller was closed:",
|
|
387
|
+
expect.anything(),
|
|
388
|
+
);
|
|
389
|
+
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
390
|
+
"argsText updated after controller was closed, restarting tool args stream:",
|
|
391
|
+
expect.anything(),
|
|
392
|
+
);
|
|
393
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
394
|
+
} finally {
|
|
395
|
+
warnSpy.mockRestore();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("closes non-executable complete args stream after run settles", () => {
|
|
400
|
+
const getTools = () => ({
|
|
401
|
+
weatherSearch: {
|
|
402
|
+
parameters: { type: "object", properties: {} },
|
|
403
|
+
} satisfies Tool,
|
|
404
|
+
});
|
|
405
|
+
const onResult = vi.fn();
|
|
406
|
+
const onStatusesChange = () => {};
|
|
407
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
411
|
+
onResult,
|
|
412
|
+
onStatusesChange,
|
|
413
|
+
});
|
|
414
|
+
tracker.setState(createState([]));
|
|
415
|
+
|
|
416
|
+
tracker.setState(
|
|
417
|
+
createState(
|
|
418
|
+
[
|
|
419
|
+
createAssistantMessage('{"title":"Weekly"}', {
|
|
420
|
+
title: "Weekly",
|
|
421
|
+
}),
|
|
422
|
+
],
|
|
423
|
+
true,
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
tracker.setState(
|
|
428
|
+
createState(
|
|
429
|
+
[
|
|
430
|
+
createAssistantMessage('{"title":"Weekly"}', {
|
|
431
|
+
title: "Weekly",
|
|
432
|
+
}),
|
|
433
|
+
],
|
|
434
|
+
false,
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
tracker.setState(
|
|
439
|
+
createState(
|
|
440
|
+
[
|
|
441
|
+
createAssistantMessage('{"title":"Weekly","columns":["name"]}', {
|
|
442
|
+
title: "Weekly",
|
|
443
|
+
columns: ["name"],
|
|
444
|
+
}),
|
|
445
|
+
],
|
|
446
|
+
false,
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// Under the exactly-once-per-toolCallId contract, an argsText change
|
|
451
|
+
// after first completion is logged but does not restart the stream.
|
|
452
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
453
|
+
expect.stringContaining("changed after first completion"),
|
|
454
|
+
expect.objectContaining({
|
|
455
|
+
previous: '{"title":"Weekly"}',
|
|
456
|
+
next: '{"title":"Weekly","columns":["name"]}',
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
460
|
+
} finally {
|
|
461
|
+
warnSpy.mockRestore();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("handles backend result when equivalent complete argsText reorders keys", async () => {
|
|
466
|
+
let resolveExecute: ((value: unknown) => void) | undefined;
|
|
467
|
+
const execute = vi.fn(
|
|
468
|
+
() =>
|
|
469
|
+
new Promise<unknown>((resolve) => {
|
|
470
|
+
resolveExecute = resolve;
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
const getTools = () => ({
|
|
474
|
+
weatherSearch: {
|
|
475
|
+
parameters: { type: "object", properties: {} },
|
|
476
|
+
execute,
|
|
477
|
+
} satisfies Tool,
|
|
478
|
+
});
|
|
479
|
+
const onResult = vi.fn();
|
|
480
|
+
const onStatusesChange = () => {};
|
|
481
|
+
|
|
482
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
483
|
+
onResult,
|
|
484
|
+
onStatusesChange,
|
|
485
|
+
});
|
|
486
|
+
tracker.setState(createState([]));
|
|
487
|
+
|
|
488
|
+
tracker.setState(
|
|
489
|
+
createState([
|
|
490
|
+
createAssistantMessage('{"a":1,"b":2}', {
|
|
491
|
+
a: 1,
|
|
492
|
+
b: 2,
|
|
493
|
+
}),
|
|
494
|
+
]),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
await waitFor(() => {
|
|
498
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
tracker.setState(
|
|
502
|
+
createState([
|
|
503
|
+
createAssistantMessage(
|
|
504
|
+
'{"b":2,"a":1}',
|
|
505
|
+
{
|
|
506
|
+
a: 1,
|
|
507
|
+
b: 2,
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
result: { source: "backend" },
|
|
511
|
+
},
|
|
512
|
+
),
|
|
513
|
+
]),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
resolveExecute?.({ source: "client" });
|
|
517
|
+
await Promise.resolve();
|
|
518
|
+
|
|
519
|
+
await waitFor(() => {
|
|
520
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("fires streamCall for already-resolved tool calls loaded after the initial snapshot", async () => {
|
|
525
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
526
|
+
const streamCall = vi.fn();
|
|
527
|
+
const getTools = () => ({
|
|
528
|
+
weatherSearch: {
|
|
529
|
+
parameters: { type: "object", properties: {} },
|
|
530
|
+
execute,
|
|
531
|
+
streamCall,
|
|
532
|
+
} satisfies Tool,
|
|
533
|
+
});
|
|
534
|
+
const onResult = vi.fn();
|
|
535
|
+
const onStatusesChangeFn = vi.fn();
|
|
536
|
+
|
|
537
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
538
|
+
onResult,
|
|
539
|
+
onStatusesChange: onStatusesChangeFn,
|
|
540
|
+
});
|
|
541
|
+
tracker.setState(createState([]));
|
|
542
|
+
|
|
543
|
+
tracker.setState(
|
|
544
|
+
createState([
|
|
545
|
+
createAssistantMessage(
|
|
546
|
+
'{"query":"London"}',
|
|
547
|
+
{ query: "London" },
|
|
548
|
+
{ result: { source: "history" } },
|
|
549
|
+
),
|
|
550
|
+
]),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
await waitFor(() => {
|
|
554
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const [reader] = streamCall.mock.calls[0]!;
|
|
558
|
+
await expect(reader.args.get("query")).resolves.toBe("London");
|
|
559
|
+
const response = await reader.response.get();
|
|
560
|
+
expect(response.result).toEqual({ source: "history" });
|
|
561
|
+
|
|
562
|
+
expect(execute).not.toHaveBeenCalled();
|
|
563
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
564
|
+
expect(onStatusesChangeFn).not.toHaveBeenCalled();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("does not fire streamCall for tool calls present in the initial snapshot", async () => {
|
|
568
|
+
const streamCall = vi.fn();
|
|
569
|
+
const getTools = () => ({
|
|
570
|
+
weatherSearch: {
|
|
571
|
+
parameters: { type: "object", properties: {} },
|
|
572
|
+
streamCall,
|
|
573
|
+
} satisfies Tool,
|
|
574
|
+
});
|
|
575
|
+
const onResult = vi.fn();
|
|
576
|
+
const onStatusesChange = () => {};
|
|
577
|
+
|
|
578
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
579
|
+
onResult,
|
|
580
|
+
onStatusesChange,
|
|
581
|
+
});
|
|
582
|
+
tracker.setState(
|
|
583
|
+
createState([
|
|
584
|
+
createAssistantMessage(
|
|
585
|
+
'{"query":"London"}',
|
|
586
|
+
{ query: "London" },
|
|
587
|
+
{ result: { source: "history" } },
|
|
588
|
+
),
|
|
589
|
+
]),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
// The original test simulated initialProps via renderHook. Here, the
|
|
593
|
+
// "initial snapshot" semantics come from `_pendingRestore` being true
|
|
594
|
+
// on the very first setState. To match, we use a fresh tracker and
|
|
595
|
+
// mark the first snapshot as a restore via isLoading.
|
|
596
|
+
// Actually: pendingRestore starts true on construction, so the first
|
|
597
|
+
// setState IS the initial snapshot. Reset the call counter expectation
|
|
598
|
+
// accordingly.
|
|
599
|
+
// (No additional setState needed.)
|
|
600
|
+
|
|
601
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
602
|
+
expect(streamCall).not.toHaveBeenCalled();
|
|
603
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("promotes an in-progress tool call from the initial snapshot when it changes", async () => {
|
|
607
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
608
|
+
const streamCall = vi.fn();
|
|
609
|
+
const getTools = () => ({
|
|
610
|
+
weatherSearch: {
|
|
611
|
+
parameters: { type: "object", properties: {} },
|
|
612
|
+
execute,
|
|
613
|
+
streamCall,
|
|
614
|
+
} satisfies Tool,
|
|
615
|
+
});
|
|
616
|
+
const onResult = vi.fn();
|
|
617
|
+
const onStatusesChange = () => {};
|
|
618
|
+
|
|
619
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
620
|
+
onResult,
|
|
621
|
+
onStatusesChange,
|
|
622
|
+
});
|
|
623
|
+
tracker.setState(
|
|
624
|
+
createState([createAssistantMessage('{"query":"Lon', { query: "Lon" })]),
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
628
|
+
expect(streamCall).not.toHaveBeenCalled();
|
|
629
|
+
|
|
630
|
+
tracker.setState(
|
|
631
|
+
createState([
|
|
632
|
+
createAssistantMessage(
|
|
633
|
+
'{"query":"London"}',
|
|
634
|
+
{ query: "London" },
|
|
635
|
+
{ result: { source: "history" } },
|
|
636
|
+
),
|
|
637
|
+
]),
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
await waitFor(() => {
|
|
641
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const [reader] = streamCall.mock.calls[0]!;
|
|
645
|
+
await expect(reader.args.get("query")).resolves.toBe("London");
|
|
646
|
+
const response = await reader.response.get();
|
|
647
|
+
expect(response.result).toEqual({ source: "history" });
|
|
648
|
+
|
|
649
|
+
expect(execute).not.toHaveBeenCalled();
|
|
650
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("does not re-fire streamCall when an initial-snapshot tool call is unchanged in later snapshots", async () => {
|
|
654
|
+
const streamCall = vi.fn();
|
|
655
|
+
const getTools = () => ({
|
|
656
|
+
weatherSearch: {
|
|
657
|
+
parameters: { type: "object", properties: {} },
|
|
658
|
+
streamCall,
|
|
659
|
+
} satisfies Tool,
|
|
660
|
+
});
|
|
661
|
+
const onResult = vi.fn();
|
|
662
|
+
const onStatusesChange = () => {};
|
|
663
|
+
|
|
664
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
665
|
+
onResult,
|
|
666
|
+
onStatusesChange,
|
|
667
|
+
});
|
|
668
|
+
tracker.setState(
|
|
669
|
+
createState([
|
|
670
|
+
createAssistantMessage(
|
|
671
|
+
'{"query":"London"}',
|
|
672
|
+
{ query: "London" },
|
|
673
|
+
{ result: { source: "history" } },
|
|
674
|
+
),
|
|
675
|
+
]),
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
tracker.setState(
|
|
679
|
+
createState([
|
|
680
|
+
createAssistantMessage(
|
|
681
|
+
'{"query":"London"}',
|
|
682
|
+
{ query: "London" },
|
|
683
|
+
{ result: { source: "history" } },
|
|
684
|
+
),
|
|
685
|
+
]),
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
689
|
+
expect(streamCall).not.toHaveBeenCalled();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("does not emit a cancellation onResult for pre-resolved tool calls aborted by reset", async () => {
|
|
693
|
+
const streamCall = vi.fn();
|
|
694
|
+
const getTools = () => ({
|
|
695
|
+
weatherSearch: {
|
|
696
|
+
parameters: { type: "object", properties: {} },
|
|
697
|
+
execute: vi.fn(async () => ({ forecast: "ok" })),
|
|
698
|
+
streamCall,
|
|
699
|
+
} satisfies Tool,
|
|
700
|
+
});
|
|
701
|
+
const onResult = vi.fn();
|
|
702
|
+
const onStatusesChange = () => {};
|
|
703
|
+
|
|
704
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
705
|
+
onResult,
|
|
706
|
+
onStatusesChange,
|
|
707
|
+
});
|
|
708
|
+
tracker.setState(createState([]));
|
|
709
|
+
|
|
710
|
+
tracker.setState(
|
|
711
|
+
createState([
|
|
712
|
+
createAssistantMessage(
|
|
713
|
+
'{"query":"London"}',
|
|
714
|
+
{ query: "London" },
|
|
715
|
+
{ result: { source: "history" } },
|
|
716
|
+
),
|
|
717
|
+
]),
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
await waitFor(() => {
|
|
721
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
tracker.reset();
|
|
725
|
+
|
|
726
|
+
// Flush microtasks through the executor's abort race + the stream
|
|
727
|
+
// pipeline so any cancellation `result` chunk has a chance to land
|
|
728
|
+
// before we assert it didn't.
|
|
729
|
+
for (let i = 0; i < 5; i++) {
|
|
730
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("fires streamCall when an initial-snapshot in-progress tool call grows its argsText (no result yet)", async () => {
|
|
737
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
738
|
+
const streamCall = vi.fn();
|
|
739
|
+
const getTools = () => ({
|
|
740
|
+
weatherSearch: {
|
|
741
|
+
parameters: { type: "object", properties: {} },
|
|
742
|
+
execute,
|
|
743
|
+
streamCall,
|
|
744
|
+
} satisfies Tool,
|
|
745
|
+
});
|
|
746
|
+
const onResult = vi.fn();
|
|
747
|
+
const onStatusesChange = () => {};
|
|
748
|
+
|
|
749
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
750
|
+
onResult,
|
|
751
|
+
onStatusesChange,
|
|
752
|
+
});
|
|
753
|
+
tracker.setState(
|
|
754
|
+
createState([createAssistantMessage('{"query":"Lon', { query: "Lon" })]),
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
758
|
+
expect(streamCall).not.toHaveBeenCalled();
|
|
759
|
+
|
|
760
|
+
tracker.setState(
|
|
761
|
+
createState([
|
|
762
|
+
createAssistantMessage('{"query":"London","detail', {
|
|
763
|
+
query: "London",
|
|
764
|
+
detail: undefined,
|
|
765
|
+
}),
|
|
766
|
+
]),
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
await waitFor(() => {
|
|
770
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// No result yet → response promise stays pending; execute is gated on
|
|
774
|
+
// complete args, so it must not have fired either.
|
|
775
|
+
expect(execute).not.toHaveBeenCalled();
|
|
776
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("fires streamCall exactly once when an initial in-progress tool call is promoted partially, then later resolved", async () => {
|
|
780
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
781
|
+
const streamCall = vi.fn();
|
|
782
|
+
const getTools = () => ({
|
|
783
|
+
weatherSearch: {
|
|
784
|
+
parameters: { type: "object", properties: {} },
|
|
785
|
+
execute,
|
|
786
|
+
streamCall,
|
|
787
|
+
} satisfies Tool,
|
|
788
|
+
});
|
|
789
|
+
const onResult = vi.fn();
|
|
790
|
+
const onStatusesChange = () => {};
|
|
791
|
+
|
|
792
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
793
|
+
onResult,
|
|
794
|
+
onStatusesChange,
|
|
795
|
+
});
|
|
796
|
+
tracker.setState(
|
|
797
|
+
createState([createAssistantMessage('{"query":"Lon', { query: "Lon" })]),
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
801
|
+
expect(streamCall).not.toHaveBeenCalled();
|
|
802
|
+
|
|
803
|
+
// First post-restore change: promote (streamCall fires).
|
|
804
|
+
tracker.setState(
|
|
805
|
+
createState([
|
|
806
|
+
createAssistantMessage('{"query":"London"', { query: "London" }),
|
|
807
|
+
]),
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
await waitFor(() => {
|
|
811
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// Subsequent live update completing args + landing a backend result.
|
|
815
|
+
// The entry is already active, so this must not re-fire streamCall.
|
|
816
|
+
tracker.setState(
|
|
817
|
+
createState([
|
|
818
|
+
createAssistantMessage(
|
|
819
|
+
'{"query":"London"}',
|
|
820
|
+
{ query: "London" },
|
|
821
|
+
{ result: { source: "history" } },
|
|
822
|
+
),
|
|
823
|
+
]),
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
const [reader] = streamCall.mock.calls[0]!;
|
|
827
|
+
const response = await reader.response.get();
|
|
828
|
+
expect(response.result).toEqual({ source: "history" });
|
|
829
|
+
|
|
830
|
+
// Subsequent partial→resolved updates after promotion must not produce
|
|
831
|
+
// a second streamCall.
|
|
832
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("exposes the resolved result on the streamCall reader for tool calls observed already-resolved live", async () => {
|
|
836
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
837
|
+
const streamCall = vi.fn();
|
|
838
|
+
const getTools = () => ({
|
|
839
|
+
weatherSearch: {
|
|
840
|
+
parameters: { type: "object", properties: {} },
|
|
841
|
+
execute,
|
|
842
|
+
streamCall,
|
|
843
|
+
} satisfies Tool,
|
|
844
|
+
});
|
|
845
|
+
const onResult = vi.fn();
|
|
846
|
+
const onStatusesChange = () => {};
|
|
847
|
+
|
|
848
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
849
|
+
onResult,
|
|
850
|
+
onStatusesChange,
|
|
851
|
+
});
|
|
852
|
+
tracker.setState(createState([]));
|
|
853
|
+
|
|
854
|
+
tracker.setState(
|
|
855
|
+
createState([
|
|
856
|
+
createAssistantMessage(
|
|
857
|
+
'{"query":"London"}',
|
|
858
|
+
{ query: "London" },
|
|
859
|
+
{ result: { city: "London", temp: 12 }, isError: false },
|
|
860
|
+
),
|
|
861
|
+
]),
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
await waitFor(() => {
|
|
865
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
const [reader] = streamCall.mock.calls[0]!;
|
|
869
|
+
const response = await reader.response.get();
|
|
870
|
+
expect(response.result).toEqual({ city: "London", temp: 12 });
|
|
871
|
+
expect(response.isError).toBe(false);
|
|
872
|
+
|
|
873
|
+
// execute is suppressed for pre-resolved tool calls so client-side
|
|
874
|
+
// side effects don't double-run.
|
|
875
|
+
expect(execute).not.toHaveBeenCalled();
|
|
876
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("fires streamCall for already-resolved nested tool calls surfaced via content.messages", async () => {
|
|
880
|
+
const parentStreamCall = vi.fn();
|
|
881
|
+
const childStreamCall = vi.fn();
|
|
882
|
+
const parentExecute = vi.fn(async () => ({ scope: "parent" }));
|
|
883
|
+
const childExecute = vi.fn(async () => ({ scope: "child" }));
|
|
884
|
+
const getTools = () => ({
|
|
885
|
+
parentTool: {
|
|
886
|
+
parameters: { type: "object", properties: {} },
|
|
887
|
+
execute: parentExecute,
|
|
888
|
+
streamCall: parentStreamCall,
|
|
889
|
+
} satisfies Tool,
|
|
890
|
+
childTool: {
|
|
891
|
+
parameters: { type: "object", properties: {} },
|
|
892
|
+
execute: childExecute,
|
|
893
|
+
streamCall: childStreamCall,
|
|
894
|
+
} satisfies Tool,
|
|
895
|
+
});
|
|
896
|
+
const onResult = vi.fn();
|
|
897
|
+
const onStatusesChange = () => {};
|
|
898
|
+
|
|
899
|
+
const nestedMessage = createAssistantMessage(
|
|
900
|
+
'{"query":"child"}',
|
|
901
|
+
{ query: "child" },
|
|
902
|
+
{
|
|
903
|
+
toolCallId: "tool-child",
|
|
904
|
+
toolName: "childTool",
|
|
905
|
+
result: { from: "nested-history" },
|
|
906
|
+
},
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
910
|
+
onResult,
|
|
911
|
+
onStatusesChange,
|
|
912
|
+
});
|
|
913
|
+
tracker.setState(createState([]));
|
|
914
|
+
|
|
915
|
+
tracker.setState(
|
|
916
|
+
createState([
|
|
917
|
+
createAssistantMessage(
|
|
918
|
+
'{"query":"parent"}',
|
|
919
|
+
{ query: "parent" },
|
|
920
|
+
{
|
|
921
|
+
toolName: "parentTool",
|
|
922
|
+
result: { from: "parent-history" },
|
|
923
|
+
nestedMessages: [nestedMessage],
|
|
924
|
+
},
|
|
925
|
+
),
|
|
926
|
+
]),
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
await waitFor(() => {
|
|
930
|
+
expect(parentStreamCall).toHaveBeenCalledTimes(1);
|
|
931
|
+
expect(childStreamCall).toHaveBeenCalledTimes(1);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
const [childReader] = childStreamCall.mock.calls[0]!;
|
|
935
|
+
const childResponse = await childReader.response.get();
|
|
936
|
+
expect(childResponse.result).toEqual({ from: "nested-history" });
|
|
937
|
+
await expect(childReader.args.get("query")).resolves.toBe("child");
|
|
938
|
+
|
|
939
|
+
expect(parentExecute).not.toHaveBeenCalled();
|
|
940
|
+
expect(childExecute).not.toHaveBeenCalled();
|
|
941
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("fires streamCall exactly once per toolCallId across the full monotonic args + backend-result lifecycle", async () => {
|
|
945
|
+
// Lock down the "exactly once per toolCallId" contract by walking a
|
|
946
|
+
// tool call through the normal lifecycle (monotonic args growth +
|
|
947
|
+
// post-completion mutations + backend result + a key reorder + a
|
|
948
|
+
// result replacement) and verifying streamCall fires exactly once.
|
|
949
|
+
//
|
|
950
|
+
// The pathological mid-stream regression case (A.2) is covered by
|
|
951
|
+
// the dedicated regression test above. Mixing A.2 with a backend
|
|
952
|
+
// result in the same snapshot exposes a separate issue inside
|
|
953
|
+
// assistant-stream's `ToolCallStreamController.setResponse` ordering
|
|
954
|
+
// (parse-failure result reaches the reader before the backend
|
|
955
|
+
// result); that's tracked separately and out of scope for the
|
|
956
|
+
// tracker-level contract.
|
|
957
|
+
const streamCall = vi.fn();
|
|
958
|
+
const execute = vi.fn(async () => ({ forecast: "ok" }));
|
|
959
|
+
const getTools = () => ({
|
|
960
|
+
weatherSearch: {
|
|
961
|
+
parameters: { type: "object", properties: {} },
|
|
962
|
+
execute,
|
|
963
|
+
streamCall,
|
|
964
|
+
} satisfies Tool,
|
|
965
|
+
});
|
|
966
|
+
const onResult = vi.fn();
|
|
967
|
+
const onStatusesChange = () => {};
|
|
968
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
const tracker = new ToolInvocationTracker(getTools, {
|
|
972
|
+
onResult,
|
|
973
|
+
onStatusesChange,
|
|
974
|
+
});
|
|
975
|
+
tracker.setState(createState([]));
|
|
976
|
+
|
|
977
|
+
// First observation, partial (monotonic).
|
|
978
|
+
tracker.setState(
|
|
979
|
+
createState([createAssistantMessage('{"a":1', { a: 1 })]),
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
await waitFor(() => {
|
|
983
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
// Args grow monotonically (A.1) — still one fire.
|
|
987
|
+
tracker.setState(
|
|
988
|
+
createState([createAssistantMessage('{"a":1,"b":', { a: 1 })]),
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
// First resolution (A.5).
|
|
992
|
+
tracker.setState(
|
|
993
|
+
createState([
|
|
994
|
+
createAssistantMessage(
|
|
995
|
+
'{"a":1,"b":3}',
|
|
996
|
+
{ a: 1, b: 3 },
|
|
997
|
+
{ result: { source: "backend" } },
|
|
998
|
+
),
|
|
999
|
+
]),
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
await waitFor(async () => {
|
|
1003
|
+
const [reader] = streamCall.mock.calls[0]!;
|
|
1004
|
+
const response = await reader.response.get();
|
|
1005
|
+
expect(response.result).toEqual({ source: "backend" });
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// A.3 key reorder of the complete argsText — still one fire.
|
|
1009
|
+
tracker.setState(
|
|
1010
|
+
createState([
|
|
1011
|
+
createAssistantMessage(
|
|
1012
|
+
'{"b":3,"a":1}',
|
|
1013
|
+
{ a: 1, b: 3 },
|
|
1014
|
+
{ result: { source: "backend" } },
|
|
1015
|
+
),
|
|
1016
|
+
]),
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
// A.6 result replacement (same toolCallId, different result) —
|
|
1020
|
+
// still one fire (the silent-ignore branch in _processMessages).
|
|
1021
|
+
tracker.setState(
|
|
1022
|
+
createState([
|
|
1023
|
+
createAssistantMessage(
|
|
1024
|
+
'{"b":3,"a":1}',
|
|
1025
|
+
{ a: 1, b: 3 },
|
|
1026
|
+
{ result: { source: "backend", revised: true } },
|
|
1027
|
+
),
|
|
1028
|
+
]),
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
// Flush any deferred work.
|
|
1032
|
+
for (let i = 0; i < 3; i++) {
|
|
1033
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// The hard contract.
|
|
1037
|
+
expect(streamCall).toHaveBeenCalledTimes(1);
|
|
1038
|
+
// execute is suppressed because the tool call resolved via a backend
|
|
1039
|
+
// result on the resolution snapshot (pre-resolved path activates
|
|
1040
|
+
// skipExecute at startActiveEntry time — but here we created the
|
|
1041
|
+
// entry pre-resolution and transitioned in. The non-skipExecute
|
|
1042
|
+
// execute path won't fire either, because by the time args close,
|
|
1043
|
+
// the reader's response has already resolved, and ToolExecutionStream
|
|
1044
|
+
// routes the result chunk back without invoking execute again).
|
|
1045
|
+
// We don't pin the exact path; just that it's at most one.
|
|
1046
|
+
expect(execute.mock.calls.length).toBeLessThanOrEqual(1);
|
|
1047
|
+
// No second onResult either (entry.hasResult short-circuits both
|
|
1048
|
+
// the parse-failure error chunk and the redundant backend result).
|
|
1049
|
+
expect(onResult).not.toHaveBeenCalled();
|
|
1050
|
+
} finally {
|
|
1051
|
+
warnSpy.mockRestore();
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
});
|