@assistant-ui/react 0.15.1 → 0.15.3
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/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +43 -47
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +199 -176
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +56 -48
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/legacy-runtime/cloud/auiV0.d.ts +26 -2
- package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.js +29 -8
- package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +25 -14
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +17 -6
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +6 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.d.ts +4 -2
- package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.js +21 -42
- package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.d.ts +4 -2
- package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/package.json +6 -6
- package/src/client/ExternalThread.ts +5 -16
- package/src/client/InMemoryThreadList.ts +27 -7
- package/src/client/SingleThreadList.ts +10 -2
- package/src/index.ts +1 -0
- package/src/legacy-runtime/cloud/auiV0.ts +59 -14
- package/src/mcp-apps/McpAppRenderer.test.tsx +262 -3
- package/src/mcp-apps/McpAppRenderer.tsx +27 -15
- package/src/mcp-apps/McpAppsRemoteHost.ts +18 -9
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -1
- package/src/primitives/suggestion/SuggestionTrigger.ts +11 -32
- package/src/primitives/thread/ThreadSuggestion.ts +2 -1
- package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +115 -0
- package/src/tests/external-thread-parity.test.tsx +108 -16
- package/src/tests/threadListItemIsRunning.test.tsx +198 -0
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
ToolCallMessagePartProps,
|
|
15
15
|
} from "@assistant-ui/core/react";
|
|
16
16
|
import { useAui } from "@assistant-ui/store";
|
|
17
|
+
import { create, type StoreApi, type UseBoundStore } from "zustand";
|
|
17
18
|
|
|
18
19
|
import { useResource, resource, type ResourceElement } from "@assistant-ui/tap";
|
|
19
20
|
import { McpAppFrame } from "./app-frame";
|
|
@@ -55,12 +56,15 @@ export type McpAppRendererOptions = {
|
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
type LoadedResourceState = {
|
|
59
|
+
host: McpAppsHost;
|
|
58
60
|
resourceUri: string;
|
|
59
61
|
serverId?: string;
|
|
60
62
|
resource?: McpAppResource;
|
|
61
63
|
error?: Error;
|
|
62
64
|
};
|
|
63
65
|
|
|
66
|
+
type UseHostStore = UseBoundStore<StoreApi<{ host: McpAppsHost }>>;
|
|
67
|
+
|
|
64
68
|
function getInput(part: {
|
|
65
69
|
status: { type: string };
|
|
66
70
|
argsText: string;
|
|
@@ -91,11 +95,11 @@ function extractSendMessageText(params: unknown): string | undefined {
|
|
|
91
95
|
|
|
92
96
|
function InlineRenderer({
|
|
93
97
|
part,
|
|
94
|
-
|
|
98
|
+
useHostStore,
|
|
95
99
|
optionsRef,
|
|
96
100
|
}: {
|
|
97
101
|
part: ToolCallMessagePartProps;
|
|
98
|
-
|
|
102
|
+
useHostStore: UseHostStore;
|
|
99
103
|
optionsRef: MutableRefObject<McpAppRendererOptions>;
|
|
100
104
|
}) {
|
|
101
105
|
const opts = optionsRef.current;
|
|
@@ -113,17 +117,19 @@ function InlineRenderer({
|
|
|
113
117
|
|
|
114
118
|
const [loadedResource, setLoadedResource] = useState<LoadedResourceState>();
|
|
115
119
|
|
|
120
|
+
const host = useHostStore((state) => state.host);
|
|
116
121
|
const resourceUri = appForRender?.resourceUri;
|
|
117
122
|
const serverId = appForRender?.serverId;
|
|
118
123
|
const serverIdRef = useRef<string | undefined>(undefined);
|
|
119
124
|
serverIdRef.current = serverId;
|
|
120
125
|
useEffect(() => {
|
|
121
|
-
if (
|
|
126
|
+
if (resourceUri == null) return;
|
|
122
127
|
let cancelled = false;
|
|
128
|
+
const targetHost = host;
|
|
123
129
|
const targetUri = resourceUri;
|
|
124
130
|
const targetServerId = serverId;
|
|
125
131
|
|
|
126
|
-
|
|
132
|
+
targetHost
|
|
127
133
|
.loadResource({
|
|
128
134
|
uri: targetUri,
|
|
129
135
|
...(targetServerId ? { serverId: targetServerId } : {}),
|
|
@@ -131,6 +137,7 @@ function InlineRenderer({
|
|
|
131
137
|
.then((res) => {
|
|
132
138
|
if (!cancelled)
|
|
133
139
|
setLoadedResource({
|
|
140
|
+
host: targetHost,
|
|
134
141
|
resourceUri: targetUri,
|
|
135
142
|
...(targetServerId !== undefined
|
|
136
143
|
? { serverId: targetServerId }
|
|
@@ -141,6 +148,7 @@ function InlineRenderer({
|
|
|
141
148
|
.catch((error: unknown) => {
|
|
142
149
|
if (!cancelled) {
|
|
143
150
|
setLoadedResource({
|
|
151
|
+
host: targetHost,
|
|
144
152
|
resourceUri: targetUri,
|
|
145
153
|
...(targetServerId !== undefined
|
|
146
154
|
? { serverId: targetServerId }
|
|
@@ -153,8 +161,7 @@ function InlineRenderer({
|
|
|
153
161
|
return () => {
|
|
154
162
|
cancelled = true;
|
|
155
163
|
};
|
|
156
|
-
|
|
157
|
-
}, [resourceUri, serverId]);
|
|
164
|
+
}, [host, resourceUri, serverId]);
|
|
158
165
|
|
|
159
166
|
const bridgeHandlers = useMemo<McpAppBridgeHandlers>(
|
|
160
167
|
() => ({
|
|
@@ -166,29 +173,30 @@ function InlineRenderer({
|
|
|
166
173
|
return { ok: true };
|
|
167
174
|
},
|
|
168
175
|
callTool: (params) =>
|
|
169
|
-
|
|
176
|
+
useHostStore.getState().host.callTool({
|
|
170
177
|
...params,
|
|
171
178
|
...(serverIdRef.current ? { serverId: serverIdRef.current } : {}),
|
|
172
179
|
}),
|
|
173
180
|
readResource: (params) =>
|
|
174
|
-
|
|
181
|
+
useHostStore.getState().host.readResource({
|
|
175
182
|
...params,
|
|
176
183
|
...(serverIdRef.current ? { serverId: serverIdRef.current } : {}),
|
|
177
184
|
}),
|
|
178
185
|
listResources: (params) => {
|
|
179
186
|
if (!serverIdRef.current) {
|
|
180
|
-
return
|
|
187
|
+
return useHostStore.getState().host.listResources(params);
|
|
181
188
|
}
|
|
182
|
-
return
|
|
189
|
+
return useHostStore.getState().host.listResources({
|
|
183
190
|
...(isRecord(params) ? params : {}),
|
|
184
191
|
serverId: serverIdRef.current,
|
|
185
192
|
});
|
|
186
193
|
},
|
|
187
194
|
}),
|
|
188
|
-
[aui,
|
|
195
|
+
[aui, useHostStore],
|
|
189
196
|
);
|
|
190
197
|
|
|
191
198
|
const loadedResourceForApp =
|
|
199
|
+
loadedResource?.host === host &&
|
|
192
200
|
loadedResource?.resourceUri === appForRender?.resourceUri &&
|
|
193
201
|
loadedResource?.serverId === appForRender?.serverId
|
|
194
202
|
? loadedResource
|
|
@@ -241,20 +249,24 @@ const useMcpAppRenderer = (
|
|
|
241
249
|
const optionsRef = useRef<McpAppRendererOptions>(options);
|
|
242
250
|
optionsRef.current = options;
|
|
243
251
|
|
|
244
|
-
const
|
|
245
|
-
|
|
252
|
+
const [useHostStore] = useState(() =>
|
|
253
|
+
create<{ host: McpAppsHost }>(() => ({ host })),
|
|
254
|
+
);
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
useHostStore.setState({ host });
|
|
257
|
+
}, [host, useHostStore]);
|
|
246
258
|
|
|
247
259
|
const render = useMemo((): ToolCallMessagePartComponent => {
|
|
248
260
|
const Render: ToolCallMessagePartComponent = (props) => (
|
|
249
261
|
<InlineRenderer
|
|
250
262
|
part={props}
|
|
251
|
-
|
|
263
|
+
useHostStore={useHostStore}
|
|
252
264
|
optionsRef={optionsRef}
|
|
253
265
|
/>
|
|
254
266
|
);
|
|
255
267
|
Render.displayName = "McpAppRenderer";
|
|
256
268
|
return Render;
|
|
257
|
-
}, []);
|
|
269
|
+
}, [useHostStore]);
|
|
258
270
|
|
|
259
271
|
return { render };
|
|
260
272
|
};
|
|
@@ -79,23 +79,32 @@ const useMcpAppsRemoteHost = (
|
|
|
79
79
|
const optionsRef = useRef(options);
|
|
80
80
|
optionsRef.current = options;
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
const url = options.url;
|
|
83
|
+
|
|
84
|
+
return useMemo((): McpAppsHost => {
|
|
85
|
+
const getCurrentOptions = (): McpAppsRemoteHostOptions => {
|
|
86
|
+
const current = optionsRef.current;
|
|
87
|
+
return {
|
|
88
|
+
url,
|
|
89
|
+
...(current.fetch !== undefined ? { fetch: current.fetch } : {}),
|
|
90
|
+
...(current.headers !== undefined ? { headers: current.headers } : {}),
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
84
94
|
loadResource: (params) =>
|
|
85
95
|
postToHost(
|
|
86
|
-
|
|
96
|
+
getCurrentOptions(),
|
|
87
97
|
"mcp-apps/read-resource",
|
|
88
98
|
params,
|
|
89
99
|
) as Promise<McpAppResource>,
|
|
90
100
|
callTool: (params) =>
|
|
91
|
-
postToHost(
|
|
101
|
+
postToHost(getCurrentOptions(), "tools/call", params),
|
|
92
102
|
readResource: (params) =>
|
|
93
|
-
postToHost(
|
|
103
|
+
postToHost(getCurrentOptions(), "resources/read", params),
|
|
94
104
|
listResources: (params) =>
|
|
95
|
-
postToHost(
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
);
|
|
105
|
+
postToHost(getCurrentOptions(), "resources/list", params),
|
|
106
|
+
};
|
|
107
|
+
}, [url]);
|
|
99
108
|
};
|
|
100
109
|
|
|
101
110
|
export const McpAppsRemoteHost = resource(useMcpAppsRemoteHost);
|
|
@@ -151,7 +151,12 @@ export namespace MessagePrimitiveUnstable_PartsGrouped {
|
|
|
151
151
|
Image?: ImageMessagePartComponent | undefined;
|
|
152
152
|
/** Component for rendering file content */
|
|
153
153
|
File?: FileMessagePartComponent | undefined;
|
|
154
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Component for rendering audio content.
|
|
156
|
+
*
|
|
157
|
+
* @deprecated Render audio through the `File` slot instead, branching
|
|
158
|
+
* on an `audio/*` mime type.
|
|
159
|
+
*/
|
|
155
160
|
Unstable_Audio?: Unstable_AudioMessagePartComponent | undefined;
|
|
156
161
|
/** Configuration for data part rendering */
|
|
157
162
|
data?:
|
|
@@ -5,12 +5,12 @@ import {
|
|
|
5
5
|
type ActionButtonProps,
|
|
6
6
|
createActionButton,
|
|
7
7
|
} from "../../utils/createActionButton";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { useAuiState } from "@assistant-ui/store";
|
|
9
|
+
import { useSuggestionTrigger as useSuggestionTriggerBehavior } from "@assistant-ui/core/react";
|
|
10
10
|
|
|
11
11
|
const useSuggestionTrigger = ({
|
|
12
12
|
send,
|
|
13
|
-
clearComposer
|
|
13
|
+
clearComposer,
|
|
14
14
|
}: {
|
|
15
15
|
/**
|
|
16
16
|
* When true, automatically sends the message.
|
|
@@ -19,7 +19,8 @@ const useSuggestionTrigger = ({
|
|
|
19
19
|
send?: boolean | undefined;
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Whether to clear the composer after sending.
|
|
22
|
+
* Whether to clear the composer after sending. A send queued while a run is
|
|
23
|
+
* in progress never clears the composer.
|
|
23
24
|
* When send is set to false, determines if composer text is replaced with suggestion (true, default),
|
|
24
25
|
* or if it's appended to the composer text (false).
|
|
25
26
|
*
|
|
@@ -27,37 +28,15 @@ const useSuggestionTrigger = ({
|
|
|
27
28
|
*/
|
|
28
29
|
clearComposer?: boolean | undefined;
|
|
29
30
|
}) => {
|
|
30
|
-
const aui = useAui();
|
|
31
|
-
const disabled = useAuiState((s) => s.thread.isDisabled);
|
|
32
31
|
const prompt = useAuiState((s) => s.suggestion.prompt);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (resolvedSend && !isRunning) {
|
|
40
|
-
aui.thread.append({
|
|
41
|
-
content: [{ type: "text", text: prompt }],
|
|
42
|
-
runConfig: aui.composer.getState().runConfig,
|
|
43
|
-
});
|
|
44
|
-
if (clearComposer) {
|
|
45
|
-
aui.composer.setText("");
|
|
46
|
-
}
|
|
47
|
-
} else {
|
|
48
|
-
if (clearComposer) {
|
|
49
|
-
aui.composer.setText(prompt);
|
|
50
|
-
} else {
|
|
51
|
-
const currentText = aui.composer.getState().text;
|
|
52
|
-
aui.composer.setText(
|
|
53
|
-
currentText.trim() ? `${currentText} ${prompt}` : prompt,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}, [aui, resolvedSend, clearComposer, prompt]);
|
|
32
|
+
const { trigger, disabled } = useSuggestionTriggerBehavior({
|
|
33
|
+
prompt,
|
|
34
|
+
send,
|
|
35
|
+
clearComposer,
|
|
36
|
+
});
|
|
58
37
|
|
|
59
38
|
if (disabled) return null;
|
|
60
|
-
return
|
|
39
|
+
return trigger;
|
|
61
40
|
};
|
|
62
41
|
|
|
63
42
|
export namespace SuggestionPrimitiveTrigger {
|
|
@@ -24,7 +24,8 @@ const useThreadSuggestion = ({
|
|
|
24
24
|
send?: boolean | undefined;
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Whether to clear the composer after sending.
|
|
27
|
+
* Whether to clear the composer after sending. A send queued while a run is
|
|
28
|
+
* in progress never clears the composer.
|
|
28
29
|
* When send is set to false, determines if composer text is replaced with suggestion (true, default),
|
|
29
30
|
* or if it's appended to the composer text (false).
|
|
30
31
|
*
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { type FC, useEffect } from "react";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
useRemoteThreadListRuntime,
|
|
8
|
+
type AssistantRuntime,
|
|
9
|
+
} from "@assistant-ui/core/react";
|
|
10
|
+
import { makeAdapter } from "./remote-thread-list-test-helpers";
|
|
11
|
+
import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
|
|
12
|
+
import { AssistantRuntimeProvider } from "../context";
|
|
13
|
+
import type { ChatModelAdapter } from "../index";
|
|
14
|
+
|
|
15
|
+
const noOpAdapter: ChatModelAdapter = {
|
|
16
|
+
async *run() {},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const renderThreadList = async (mounts: { count: number }) => {
|
|
20
|
+
const capture: { runtime: AssistantRuntime | null } = { runtime: null };
|
|
21
|
+
|
|
22
|
+
const adapter = makeAdapter({
|
|
23
|
+
list: async () => ({
|
|
24
|
+
threads: [
|
|
25
|
+
{
|
|
26
|
+
status: "regular" as const,
|
|
27
|
+
remoteId: "t-1",
|
|
28
|
+
externalId: "t-1",
|
|
29
|
+
title: "Open",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const Inner: FC = () => {
|
|
36
|
+
const runtime = useRemoteThreadListRuntime({
|
|
37
|
+
runtimeHook: function useTestRuntimeHook() {
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
mounts.count++;
|
|
40
|
+
}, []);
|
|
41
|
+
return useLocalRuntime(noOpAdapter);
|
|
42
|
+
},
|
|
43
|
+
adapter,
|
|
44
|
+
});
|
|
45
|
+
capture.runtime = runtime;
|
|
46
|
+
return (
|
|
47
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
48
|
+
{null}
|
|
49
|
+
</AssistantRuntimeProvider>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
await act(async () => {
|
|
54
|
+
render(<Inner />);
|
|
55
|
+
});
|
|
56
|
+
await waitFor(() => expect(capture.runtime).not.toBeNull());
|
|
57
|
+
return capture;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// binders mount asynchronously, so a snapshot taken too early attributes a
|
|
61
|
+
// still-pending mount to whatever ran next
|
|
62
|
+
const settle = async (mounts: { count: number }) => {
|
|
63
|
+
let previous = -1;
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
const seen = mounts.count;
|
|
66
|
+
const stable = seen === previous;
|
|
67
|
+
previous = seen;
|
|
68
|
+
expect(stable).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
return mounts.count;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe("threads.reloadMainThread", () => {
|
|
74
|
+
it("remounts the runtime hook of the open thread", async () => {
|
|
75
|
+
const mounts = { count: 0 };
|
|
76
|
+
const capture = await renderThreadList(mounts);
|
|
77
|
+
const runtime = capture.runtime!;
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
await runtime.threads.switchToThread("t-1");
|
|
81
|
+
});
|
|
82
|
+
const beforeReload = await settle(mounts);
|
|
83
|
+
expect(beforeReload).toBeGreaterThan(0);
|
|
84
|
+
|
|
85
|
+
await act(async () => {
|
|
86
|
+
await runtime.threads.reloadMainThread();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// the hook ran again, which is what re-runs the adapter's load()
|
|
90
|
+
await waitFor(() => expect(mounts.count).toBe(beforeReload + 1));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps a thread runtime readable across the remount", async () => {
|
|
94
|
+
const mounts = { count: 0 };
|
|
95
|
+
const capture = await renderThreadList(mounts);
|
|
96
|
+
const runtime = capture.runtime!;
|
|
97
|
+
|
|
98
|
+
await act(async () => {
|
|
99
|
+
await runtime.threads.switchToThread("t-1");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const seen: boolean[] = [];
|
|
103
|
+
const unsubscribe = runtime.threads.subscribe(() => {
|
|
104
|
+
seen.push(runtime.threads.getState().mainThreadId === "t-1");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await act(async () => {
|
|
108
|
+
await runtime.threads.reloadMainThread();
|
|
109
|
+
});
|
|
110
|
+
unsubscribe();
|
|
111
|
+
|
|
112
|
+
expect(seen.length).toBeGreaterThan(0);
|
|
113
|
+
expect(seen.every(Boolean)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -36,29 +36,36 @@ const renderThread = (props: ExternalThreadProps) => {
|
|
|
36
36
|
};
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
const
|
|
39
|
+
const assistantMessageWithContent = (
|
|
40
40
|
status: ThreadMessage["status"],
|
|
41
|
-
|
|
41
|
+
content: ExternalThreadMessage["content"],
|
|
42
|
+
id = "a1",
|
|
42
43
|
): ExternalThreadMessage =>
|
|
43
44
|
({
|
|
44
|
-
id
|
|
45
|
+
id,
|
|
45
46
|
role: "assistant",
|
|
46
|
-
content
|
|
47
|
-
{ type: "text", text: "let me check" },
|
|
48
|
-
{
|
|
49
|
-
type: "tool-call",
|
|
50
|
-
toolCallId: "tc1",
|
|
51
|
-
toolName: "probe_tool",
|
|
52
|
-
args: {},
|
|
53
|
-
argsText: "{}",
|
|
54
|
-
...(result !== undefined && { result }),
|
|
55
|
-
},
|
|
56
|
-
],
|
|
47
|
+
content,
|
|
57
48
|
createdAt: new Date(0),
|
|
58
49
|
status,
|
|
59
50
|
metadata: { custom: {} },
|
|
60
51
|
}) as unknown as ExternalThreadMessage;
|
|
61
52
|
|
|
53
|
+
const assistantMessage = (
|
|
54
|
+
status: ThreadMessage["status"],
|
|
55
|
+
result?: string,
|
|
56
|
+
): ExternalThreadMessage =>
|
|
57
|
+
assistantMessageWithContent(status, [
|
|
58
|
+
{ type: "text", text: "let me check" },
|
|
59
|
+
{
|
|
60
|
+
type: "tool-call",
|
|
61
|
+
toolCallId: "tc1",
|
|
62
|
+
toolName: "probe_tool",
|
|
63
|
+
args: {},
|
|
64
|
+
argsText: "{}",
|
|
65
|
+
...(result !== undefined && { result }),
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
|
|
62
69
|
describe("ExternalThread part status", () => {
|
|
63
70
|
it("gives an unresolved tool call its message's status", () => {
|
|
64
71
|
const { aui } = renderThread({
|
|
@@ -70,14 +77,99 @@ describe("ExternalThread part status", () => {
|
|
|
70
77
|
expect(part("tc1").status).toEqual({ type: "running" });
|
|
71
78
|
});
|
|
72
79
|
|
|
73
|
-
it("
|
|
80
|
+
it("uses positional fallback for statusless parts and resolved tool calls", () => {
|
|
74
81
|
const { aui } = renderThread({
|
|
75
|
-
messages: [
|
|
82
|
+
messages: [
|
|
83
|
+
assistantMessage({ type: "running" }, "ok"),
|
|
84
|
+
assistantMessageWithContent(
|
|
85
|
+
{ type: "running" },
|
|
86
|
+
[
|
|
87
|
+
{ type: "text", text: "first" },
|
|
88
|
+
{ type: "reasoning", text: "last" },
|
|
89
|
+
],
|
|
90
|
+
"a2",
|
|
91
|
+
),
|
|
92
|
+
],
|
|
76
93
|
isRunning: true,
|
|
77
94
|
});
|
|
78
95
|
const state = aui().thread.message({ id: "a1" }).getState();
|
|
79
96
|
expect(state.parts[0]!.status).toEqual({ type: "complete" });
|
|
80
97
|
expect(state.parts[1]!.status).toEqual({ type: "complete" });
|
|
98
|
+
const fallbackState = aui().thread.message({ id: "a2" }).getState();
|
|
99
|
+
expect(fallbackState.parts[0]!.status).toEqual({ type: "complete" });
|
|
100
|
+
expect(fallbackState.parts[1]!.status).toEqual({ type: "running" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("honours supplied statuses while the message is running", () => {
|
|
104
|
+
const { aui } = renderThread({
|
|
105
|
+
messages: [
|
|
106
|
+
assistantMessageWithContent({ type: "running" }, [
|
|
107
|
+
{ type: "text", text: "first", status: { type: "running" } },
|
|
108
|
+
{
|
|
109
|
+
type: "reasoning",
|
|
110
|
+
text: "last",
|
|
111
|
+
status: { type: "complete" },
|
|
112
|
+
},
|
|
113
|
+
]),
|
|
114
|
+
],
|
|
115
|
+
isRunning: true,
|
|
116
|
+
});
|
|
117
|
+
const state = aui().thread.message({ id: "a1" }).getState();
|
|
118
|
+
|
|
119
|
+
expect(state.parts[0]!.status).toEqual({ type: "running" });
|
|
120
|
+
expect(state.parts[1]!.status).toEqual({ type: "complete" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("ignores supplied statuses after the message completes", () => {
|
|
124
|
+
const { aui } = renderThread({
|
|
125
|
+
messages: [
|
|
126
|
+
assistantMessageWithContent({ type: "complete", reason: "stop" }, [
|
|
127
|
+
{ type: "text", text: "truncated", status: { type: "running" } },
|
|
128
|
+
]),
|
|
129
|
+
],
|
|
130
|
+
isRunning: false,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(
|
|
134
|
+
aui().thread.message({ id: "a1" }).part({ index: 0 }).getState().status,
|
|
135
|
+
).toEqual({ type: "complete", reason: "stop" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("normalizes supplied upstream statuses", () => {
|
|
139
|
+
const { aui } = renderThread({
|
|
140
|
+
messages: [
|
|
141
|
+
assistantMessageWithContent(
|
|
142
|
+
{ type: "running" },
|
|
143
|
+
// assistant-stream sends shapes core's MessagePartStatus does not
|
|
144
|
+
// declare (a reason on complete, an unlisted incomplete reason);
|
|
145
|
+
// the normalizer absorbs them.
|
|
146
|
+
[
|
|
147
|
+
{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: "done",
|
|
150
|
+
status: { type: "complete", reason: "unknown" },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: "reasoning",
|
|
154
|
+
text: "interrupted",
|
|
155
|
+
status: {
|
|
156
|
+
type: "incomplete",
|
|
157
|
+
reason: "unknown",
|
|
158
|
+
error: "upstream error",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
] as unknown as ExternalThreadMessage["content"],
|
|
162
|
+
),
|
|
163
|
+
],
|
|
164
|
+
isRunning: true,
|
|
165
|
+
});
|
|
166
|
+
const state = aui().thread.message({ id: "a1" }).getState();
|
|
167
|
+
|
|
168
|
+
expect(state.parts[0]!.status).toEqual({ type: "complete" });
|
|
169
|
+
expect(state.parts[1]!.status).toEqual({
|
|
170
|
+
type: "incomplete",
|
|
171
|
+
reason: "other",
|
|
172
|
+
});
|
|
81
173
|
});
|
|
82
174
|
|
|
83
175
|
it("keeps parts complete on requires-action and user messages", () => {
|