@assistant-ui/react 0.15.11 → 0.15.13
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/augmentations.d.ts +3 -25
- package/dist/augmentations.d.ts.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.js +1 -3
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/package.json +5 -6
- package/src/augmentations.ts +4 -2
- package/src/index.ts +2 -2
- package/src/tests/augmentations.test.ts +39 -0
- package/src/tests/in-memory-thread-list.test.tsx +1 -1
- package/dist/client/ExternalThread.d.ts +0 -48
- package/dist/client/ExternalThread.d.ts.map +0 -1
- package/dist/client/ExternalThread.js +0 -1348
- package/dist/client/ExternalThread.js.map +0 -1
- package/dist/client/SingleThreadList.d.ts +0 -9
- package/dist/client/SingleThreadList.d.ts.map +0 -1
- package/dist/client/SingleThreadList.js +0 -171
- package/dist/client/SingleThreadList.js.map +0 -1
- package/src/client/ExternalThread.ts +0 -1147
- package/src/client/SingleThreadList.ts +0 -118
- package/src/tests/external-thread-attachments.test.tsx +0 -370
- package/src/tests/external-thread-branches.test.tsx +0 -160
- package/src/tests/external-thread-feedback.test.tsx +0 -204
- package/src/tests/external-thread-parity.test.tsx +0 -494
- package/src/tests/external-thread-speech.test.tsx +0 -328
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
|
|
3
|
-
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
-
import type { FC } from "react";
|
|
5
|
-
import { describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { AuiProvider, useAui } from "@assistant-ui/store";
|
|
7
|
-
import type { FeedbackAdapter } from "@assistant-ui/core";
|
|
8
|
-
import type {
|
|
9
|
-
ExternalThreadMessage,
|
|
10
|
-
ExternalThreadProps,
|
|
11
|
-
} from "../client/ExternalThread";
|
|
12
|
-
import { ExternalThread } from "../client/ExternalThread";
|
|
13
|
-
|
|
14
|
-
const MESSAGES = [
|
|
15
|
-
{
|
|
16
|
-
id: "u1",
|
|
17
|
-
role: "user",
|
|
18
|
-
content: [{ type: "text", text: "hi" }],
|
|
19
|
-
createdAt: new Date(0),
|
|
20
|
-
attachments: [],
|
|
21
|
-
metadata: { custom: {} },
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
id: "a1",
|
|
25
|
-
role: "assistant",
|
|
26
|
-
content: [{ type: "text", text: "hello there" }],
|
|
27
|
-
createdAt: new Date(0),
|
|
28
|
-
metadata: { custom: {} },
|
|
29
|
-
},
|
|
30
|
-
] as unknown as readonly ExternalThreadMessage[];
|
|
31
|
-
|
|
32
|
-
const createFakeAdapter = () => {
|
|
33
|
-
const submit = vi.fn();
|
|
34
|
-
const adapter: FeedbackAdapter = { submit };
|
|
35
|
-
return { adapter, submit };
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const renderThreadWithProps = (props: Partial<ExternalThreadProps>) => {
|
|
39
|
-
const captured: { aui?: ReturnType<typeof useAui> } = {};
|
|
40
|
-
const Capture: FC = () => {
|
|
41
|
-
captured.aui = useAui();
|
|
42
|
-
return null;
|
|
43
|
-
};
|
|
44
|
-
const App: FC<{ props: Partial<ExternalThreadProps> }> = ({ props }) => {
|
|
45
|
-
const aui = useAui({
|
|
46
|
-
thread: ExternalThread({
|
|
47
|
-
messages: MESSAGES,
|
|
48
|
-
isRunning: false,
|
|
49
|
-
...props,
|
|
50
|
-
}),
|
|
51
|
-
});
|
|
52
|
-
return (
|
|
53
|
-
<AuiProvider value={aui}>
|
|
54
|
-
<Capture />
|
|
55
|
-
</AuiProvider>
|
|
56
|
-
);
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const view = render(<App props={props} />);
|
|
60
|
-
return {
|
|
61
|
-
aui: () => captured.aui!,
|
|
62
|
-
rerender: (nextProps: Partial<ExternalThreadProps>) =>
|
|
63
|
-
view.rerender(<App props={nextProps} />),
|
|
64
|
-
};
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
describe("ExternalThread feedback", () => {
|
|
68
|
-
it("reports the feedback capability based on adapter presence", () => {
|
|
69
|
-
const { aui: withoutAdapter } = renderThreadWithProps({});
|
|
70
|
-
expect(withoutAdapter().thread.getState().capabilities.feedback).toBe(
|
|
71
|
-
false,
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
const { adapter } = createFakeAdapter();
|
|
75
|
-
const { aui: withAdapter } = renderThreadWithProps({
|
|
76
|
-
feedbackAdapter: adapter,
|
|
77
|
-
});
|
|
78
|
-
expect(withAdapter().thread.getState().capabilities.feedback).toBe(true);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("throws on submitFeedback when no adapter is configured", () => {
|
|
82
|
-
const { aui } = renderThreadWithProps({});
|
|
83
|
-
expect(() =>
|
|
84
|
-
aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" }),
|
|
85
|
-
).toThrow("Feedback adapter not configured");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("submits feedback to the adapter and marks the assistant message", async () => {
|
|
89
|
-
const { adapter, submit } = createFakeAdapter();
|
|
90
|
-
const { aui } = renderThreadWithProps({ feedbackAdapter: adapter });
|
|
91
|
-
|
|
92
|
-
await act(async () => {
|
|
93
|
-
aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" });
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
expect(submit).toHaveBeenCalledTimes(1);
|
|
97
|
-
expect(submit).toHaveBeenCalledWith({
|
|
98
|
-
message: MESSAGES[1],
|
|
99
|
-
type: "positive",
|
|
100
|
-
});
|
|
101
|
-
await waitFor(() => {
|
|
102
|
-
expect(
|
|
103
|
-
aui().thread.message({ id: "a1" }).getState().metadata
|
|
104
|
-
.submittedFeedback,
|
|
105
|
-
).toEqual({ type: "positive" });
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
await act(async () => {
|
|
109
|
-
aui().thread.message({ id: "a1" }).submitFeedback({ type: "negative" });
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
expect(submit).toHaveBeenLastCalledWith({
|
|
113
|
-
message: MESSAGES[1],
|
|
114
|
-
type: "negative",
|
|
115
|
-
});
|
|
116
|
-
await waitFor(() => {
|
|
117
|
-
expect(
|
|
118
|
-
aui().thread.message({ id: "a1" }).getState().metadata
|
|
119
|
-
.submittedFeedback,
|
|
120
|
-
).toEqual({ type: "negative" });
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("prefers owner-supplied submittedFeedback over the local overlay", async () => {
|
|
125
|
-
const { adapter } = createFakeAdapter();
|
|
126
|
-
const { aui, rerender } = renderThreadWithProps({
|
|
127
|
-
feedbackAdapter: adapter,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
await act(async () => {
|
|
131
|
-
aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" });
|
|
132
|
-
});
|
|
133
|
-
await waitFor(() => {
|
|
134
|
-
expect(
|
|
135
|
-
aui().thread.message({ id: "a1" }).getState().metadata
|
|
136
|
-
.submittedFeedback,
|
|
137
|
-
).toEqual({ type: "positive" });
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const ownerMessages = [
|
|
141
|
-
MESSAGES[0]!,
|
|
142
|
-
{
|
|
143
|
-
...MESSAGES[1]!,
|
|
144
|
-
metadata: { custom: {}, submittedFeedback: { type: "negative" } },
|
|
145
|
-
},
|
|
146
|
-
] as unknown as readonly ExternalThreadMessage[];
|
|
147
|
-
await act(async () => {
|
|
148
|
-
rerender({ feedbackAdapter: adapter, messages: ownerMessages });
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
expect(
|
|
152
|
-
aui().thread.message({ id: "a1" }).getState().metadata.submittedFeedback,
|
|
153
|
-
).toEqual({ type: "negative" });
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("re-rates an owner-marked message locally, then honors an owner clear", async () => {
|
|
157
|
-
const { adapter } = createFakeAdapter();
|
|
158
|
-
const ratedMessages = [
|
|
159
|
-
MESSAGES[0]!,
|
|
160
|
-
{
|
|
161
|
-
...MESSAGES[1]!,
|
|
162
|
-
metadata: { custom: {}, submittedFeedback: { type: "positive" } },
|
|
163
|
-
},
|
|
164
|
-
] as unknown as readonly ExternalThreadMessage[];
|
|
165
|
-
const { aui, rerender } = renderThreadWithProps({
|
|
166
|
-
feedbackAdapter: adapter,
|
|
167
|
-
messages: ratedMessages,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
await act(async () => {
|
|
171
|
-
aui().thread.message({ id: "a1" }).submitFeedback({ type: "negative" });
|
|
172
|
-
});
|
|
173
|
-
await waitFor(() => {
|
|
174
|
-
expect(
|
|
175
|
-
aui().thread.message({ id: "a1" }).getState().metadata
|
|
176
|
-
.submittedFeedback,
|
|
177
|
-
).toEqual({ type: "negative" });
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
await act(async () => {
|
|
181
|
-
rerender({ feedbackAdapter: adapter, messages: MESSAGES });
|
|
182
|
-
});
|
|
183
|
-
expect(
|
|
184
|
-
aui().thread.message({ id: "a1" }).getState().metadata.submittedFeedback,
|
|
185
|
-
).toBeUndefined();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("submits user message feedback without marking the message", async () => {
|
|
189
|
-
const { adapter, submit } = createFakeAdapter();
|
|
190
|
-
const { aui } = renderThreadWithProps({ feedbackAdapter: adapter });
|
|
191
|
-
|
|
192
|
-
await act(async () => {
|
|
193
|
-
aui().thread.message({ id: "u1" }).submitFeedback({ type: "negative" });
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
expect(submit).toHaveBeenCalledWith({
|
|
197
|
-
message: MESSAGES[0],
|
|
198
|
-
type: "negative",
|
|
199
|
-
});
|
|
200
|
-
expect(
|
|
201
|
-
aui().thread.message({ id: "u1" }).getState().metadata.submittedFeedback,
|
|
202
|
-
).toBeUndefined();
|
|
203
|
-
});
|
|
204
|
-
});
|
|
@@ -1,494 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
|
|
3
|
-
// Legacy-runtime parity: part statuses derive from the message status, and
|
|
4
|
-
// imperative composer call sequences observe writes before React re-renders.
|
|
5
|
-
|
|
6
|
-
import { render, waitFor } from "@testing-library/react";
|
|
7
|
-
import type { FC } from "react";
|
|
8
|
-
import { describe, it, expect, vi } from "vitest";
|
|
9
|
-
import { useAui, AuiProvider } from "@assistant-ui/store";
|
|
10
|
-
import type { ThreadMessage } from "@assistant-ui/core";
|
|
11
|
-
import {
|
|
12
|
-
ExternalThread,
|
|
13
|
-
type ExternalThreadProps,
|
|
14
|
-
type ExternalThreadMessage,
|
|
15
|
-
} from "../client/ExternalThread";
|
|
16
|
-
|
|
17
|
-
const renderThread = (props: ExternalThreadProps) => {
|
|
18
|
-
const captured: { aui?: ReturnType<typeof useAui> } = {};
|
|
19
|
-
const Capture: FC = () => {
|
|
20
|
-
captured.aui = useAui();
|
|
21
|
-
return null;
|
|
22
|
-
};
|
|
23
|
-
const App: FC<{ threadProps: ExternalThreadProps }> = ({ threadProps }) => {
|
|
24
|
-
const aui = useAui({ thread: ExternalThread(threadProps) });
|
|
25
|
-
return (
|
|
26
|
-
<AuiProvider value={aui}>
|
|
27
|
-
<Capture />
|
|
28
|
-
</AuiProvider>
|
|
29
|
-
);
|
|
30
|
-
};
|
|
31
|
-
const utils = render(<App threadProps={props} />);
|
|
32
|
-
return {
|
|
33
|
-
aui: () => captured.aui!,
|
|
34
|
-
rerender: (next: ExternalThreadProps) =>
|
|
35
|
-
utils.rerender(<App threadProps={next} />),
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const assistantMessageWithContent = (
|
|
40
|
-
status: ThreadMessage["status"],
|
|
41
|
-
content: ExternalThreadMessage["content"],
|
|
42
|
-
id = "a1",
|
|
43
|
-
): ExternalThreadMessage =>
|
|
44
|
-
({
|
|
45
|
-
id,
|
|
46
|
-
role: "assistant",
|
|
47
|
-
content,
|
|
48
|
-
createdAt: new Date(0),
|
|
49
|
-
status,
|
|
50
|
-
metadata: { custom: {} },
|
|
51
|
-
}) as unknown as ExternalThreadMessage;
|
|
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
|
-
|
|
69
|
-
describe("ExternalThread part status", () => {
|
|
70
|
-
it("gives an unresolved tool call its message's status", () => {
|
|
71
|
-
const { aui } = renderThread({
|
|
72
|
-
messages: [assistantMessage({ type: "running" })],
|
|
73
|
-
isRunning: true,
|
|
74
|
-
});
|
|
75
|
-
const part = (toolCallId: string) =>
|
|
76
|
-
aui().thread.message({ id: "a1" }).part({ toolCallId }).getState();
|
|
77
|
-
expect(part("tc1").status).toEqual({ type: "running" });
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("uses positional fallback for statusless parts and resolved tool calls", () => {
|
|
81
|
-
const { aui } = renderThread({
|
|
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
|
-
],
|
|
93
|
-
isRunning: true,
|
|
94
|
-
});
|
|
95
|
-
const state = aui().thread.message({ id: "a1" }).getState();
|
|
96
|
-
expect(state.parts[0]!.status).toEqual({ type: "complete" });
|
|
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
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("keeps parts complete on requires-action and user messages", () => {
|
|
176
|
-
const { aui } = renderThread({
|
|
177
|
-
messages: [
|
|
178
|
-
{
|
|
179
|
-
id: "u1",
|
|
180
|
-
role: "user",
|
|
181
|
-
content: [{ type: "text", text: "hi" }],
|
|
182
|
-
createdAt: new Date(0),
|
|
183
|
-
attachments: [],
|
|
184
|
-
metadata: { custom: {} },
|
|
185
|
-
} as unknown as ExternalThreadMessage,
|
|
186
|
-
assistantMessage({ type: "requires-action", reason: "tool-calls" }),
|
|
187
|
-
],
|
|
188
|
-
isRunning: false,
|
|
189
|
-
});
|
|
190
|
-
expect(
|
|
191
|
-
aui().thread.message({ id: "u1" }).part({ index: 0 }).getState().status,
|
|
192
|
-
).toEqual({ type: "complete" });
|
|
193
|
-
expect(
|
|
194
|
-
aui()
|
|
195
|
-
.thread()
|
|
196
|
-
.message({ id: "a1" })
|
|
197
|
-
.part({ toolCallId: "tc1" })
|
|
198
|
-
.getState().status,
|
|
199
|
-
).toEqual({ type: "requires-action", reason: "tool-calls" });
|
|
200
|
-
expect(
|
|
201
|
-
aui().thread.message({ id: "a1" }).part({ index: 0 }).getState().status,
|
|
202
|
-
).toEqual({ type: "complete" });
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
describe("ExternalThread unset optional callbacks", () => {
|
|
207
|
-
it("throws a capability error when the callback prop is not set", () => {
|
|
208
|
-
const { aui } = renderThread({
|
|
209
|
-
messages: [
|
|
210
|
-
assistantMessage({ type: "requires-action", reason: "tool-calls" }),
|
|
211
|
-
],
|
|
212
|
-
isRunning: false,
|
|
213
|
-
});
|
|
214
|
-
const part = () =>
|
|
215
|
-
aui().thread.message({ id: "a1" }).part({ toolCallId: "tc1" });
|
|
216
|
-
|
|
217
|
-
expect(() => part().addToolResult("ok")).toThrow(
|
|
218
|
-
"Runtime does not support tool results (onAddToolResult is not set).",
|
|
219
|
-
);
|
|
220
|
-
expect(() => part().resumeToolCall(undefined)).toThrow(
|
|
221
|
-
"Runtime does not support resuming tool calls (onResumeToolCall is not set).",
|
|
222
|
-
);
|
|
223
|
-
expect(() => aui().thread.resumeRun()).toThrow(
|
|
224
|
-
"Runtime does not support resuming runs (onResume is not set).",
|
|
225
|
-
);
|
|
226
|
-
expect(() => aui().thread.importExternalState({})).toThrow(
|
|
227
|
-
"Runtime does not support importing external states (onLoadExternalState is not set).",
|
|
228
|
-
);
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
describe("ExternalThread composer", () => {
|
|
233
|
-
it("dispatches a synchronous setText + send sequence", async () => {
|
|
234
|
-
const onNew = vi.fn();
|
|
235
|
-
const { aui } = renderThread({ messages: [], isRunning: false, onNew });
|
|
236
|
-
|
|
237
|
-
aui().thread.composer().setText("hello");
|
|
238
|
-
aui().thread.composer().send();
|
|
239
|
-
|
|
240
|
-
await waitFor(() => expect(onNew).toHaveBeenCalledTimes(1));
|
|
241
|
-
expect(onNew.mock.calls[0]![0].content).toEqual([
|
|
242
|
-
{ type: "text", text: "hello" },
|
|
243
|
-
]);
|
|
244
|
-
await waitFor(() => expect(aui().thread.getState().composer.text).toBe(""));
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("stamps the thread head as parentId on queue-adapter sends", async () => {
|
|
248
|
-
const enqueue = vi.fn();
|
|
249
|
-
const steer = vi.fn();
|
|
250
|
-
const { aui } = renderThread({
|
|
251
|
-
messages: [
|
|
252
|
-
{
|
|
253
|
-
id: "u1",
|
|
254
|
-
role: "user",
|
|
255
|
-
content: [{ type: "text", text: "hi" }],
|
|
256
|
-
createdAt: new Date(0),
|
|
257
|
-
attachments: [],
|
|
258
|
-
metadata: { custom: {} },
|
|
259
|
-
} as unknown as ExternalThreadMessage,
|
|
260
|
-
],
|
|
261
|
-
isRunning: true,
|
|
262
|
-
queue: {
|
|
263
|
-
items: [],
|
|
264
|
-
steerItems: [],
|
|
265
|
-
enqueue,
|
|
266
|
-
steer,
|
|
267
|
-
move: vi.fn(),
|
|
268
|
-
edit: vi.fn(),
|
|
269
|
-
remove: vi.fn(),
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
aui().thread.composer().setText("queued");
|
|
274
|
-
aui().thread.composer().send();
|
|
275
|
-
|
|
276
|
-
// mid-run sends default to the steer lane
|
|
277
|
-
await waitFor(() => expect(steer).toHaveBeenCalledTimes(1));
|
|
278
|
-
expect(steer.mock.calls[0]![0].parentId).toBe("u1");
|
|
279
|
-
expect(enqueue).not.toHaveBeenCalled();
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it("routes edit-composer sends to onEdit with sourceId, bypassing the queue", async () => {
|
|
283
|
-
const onEdit = vi.fn();
|
|
284
|
-
const enqueue = vi.fn();
|
|
285
|
-
const steer = vi.fn();
|
|
286
|
-
const { aui } = renderThread({
|
|
287
|
-
messages: [
|
|
288
|
-
{
|
|
289
|
-
id: "u1",
|
|
290
|
-
role: "user",
|
|
291
|
-
content: [{ type: "text", text: "hi" }],
|
|
292
|
-
createdAt: new Date(0),
|
|
293
|
-
attachments: [],
|
|
294
|
-
metadata: { custom: {} },
|
|
295
|
-
} as unknown as ExternalThreadMessage,
|
|
296
|
-
],
|
|
297
|
-
isRunning: false,
|
|
298
|
-
onEdit,
|
|
299
|
-
queue: {
|
|
300
|
-
items: [],
|
|
301
|
-
steerItems: [],
|
|
302
|
-
enqueue,
|
|
303
|
-
steer,
|
|
304
|
-
move: vi.fn(),
|
|
305
|
-
edit: vi.fn(),
|
|
306
|
-
remove: vi.fn(),
|
|
307
|
-
},
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
const composer = () => aui().thread.message({ id: "u1" }).composer();
|
|
311
|
-
composer().beginEdit();
|
|
312
|
-
await waitFor(() => expect(composer().getState().isEditing).toBe(true));
|
|
313
|
-
composer().setText("edited");
|
|
314
|
-
composer().send();
|
|
315
|
-
|
|
316
|
-
await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1));
|
|
317
|
-
expect(onEdit.mock.calls[0]![0]).toMatchObject({
|
|
318
|
-
sourceId: "u1",
|
|
319
|
-
content: [{ type: "text", text: "edited" }],
|
|
320
|
-
});
|
|
321
|
-
expect(enqueue).not.toHaveBeenCalled();
|
|
322
|
-
expect(steer).not.toHaveBeenCalled();
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it("dispatches a same-tick beginEdit + setText + send sequence", async () => {
|
|
326
|
-
const onEdit = vi.fn();
|
|
327
|
-
const { aui } = renderThread({
|
|
328
|
-
messages: [
|
|
329
|
-
{
|
|
330
|
-
id: "u1",
|
|
331
|
-
role: "user",
|
|
332
|
-
content: [{ type: "text", text: "hi" }],
|
|
333
|
-
createdAt: new Date(0),
|
|
334
|
-
attachments: [],
|
|
335
|
-
metadata: { custom: {} },
|
|
336
|
-
} as unknown as ExternalThreadMessage,
|
|
337
|
-
],
|
|
338
|
-
isRunning: false,
|
|
339
|
-
onEdit,
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
const composer = () => aui().thread.message({ id: "u1" }).composer();
|
|
343
|
-
composer().beginEdit();
|
|
344
|
-
composer().setText("edited");
|
|
345
|
-
composer().send();
|
|
346
|
-
|
|
347
|
-
await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1));
|
|
348
|
-
expect(onEdit.mock.calls[0]![0]).toMatchObject({
|
|
349
|
-
sourceId: "u1",
|
|
350
|
-
content: [{ type: "text", text: "edited" }],
|
|
351
|
-
});
|
|
352
|
-
await waitFor(() => expect(composer().getState().isEditing).toBe(false));
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it("prefills the edit composer from the message on beginEdit", async () => {
|
|
356
|
-
const onEdit = vi.fn();
|
|
357
|
-
const { aui } = renderThread({
|
|
358
|
-
messages: [
|
|
359
|
-
{
|
|
360
|
-
id: "a1",
|
|
361
|
-
role: "assistant",
|
|
362
|
-
content: [{ type: "text", text: "original answer" }],
|
|
363
|
-
createdAt: new Date(0),
|
|
364
|
-
attachments: [
|
|
365
|
-
{
|
|
366
|
-
id: "att1",
|
|
367
|
-
type: "file",
|
|
368
|
-
name: "a.txt",
|
|
369
|
-
contentType: "text/plain",
|
|
370
|
-
status: { type: "complete" },
|
|
371
|
-
content: [],
|
|
372
|
-
},
|
|
373
|
-
],
|
|
374
|
-
metadata: { custom: {} },
|
|
375
|
-
} as unknown as ExternalThreadMessage,
|
|
376
|
-
],
|
|
377
|
-
isRunning: false,
|
|
378
|
-
onEdit,
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
const composer = () => aui().thread.message({ id: "a1" }).composer();
|
|
382
|
-
composer().beginEdit();
|
|
383
|
-
await waitFor(() => {
|
|
384
|
-
const state = composer().getState();
|
|
385
|
-
expect(state.text).toBe("original answer");
|
|
386
|
-
expect(state.role).toBe("assistant");
|
|
387
|
-
expect(state.attachments).toHaveLength(1);
|
|
388
|
-
});
|
|
389
|
-
expect(() => composer().beginEdit()).toThrow("Edit already in progress");
|
|
390
|
-
|
|
391
|
-
composer().send();
|
|
392
|
-
await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1));
|
|
393
|
-
expect(onEdit.mock.calls[0]![0]).toMatchObject({
|
|
394
|
-
sourceId: "a1",
|
|
395
|
-
content: [{ type: "text", text: "original answer" }],
|
|
396
|
-
});
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
it("throws on edit-composer send before beginEdit", () => {
|
|
400
|
-
const { aui } = renderThread({
|
|
401
|
-
messages: [
|
|
402
|
-
{
|
|
403
|
-
id: "u1",
|
|
404
|
-
role: "user",
|
|
405
|
-
content: [{ type: "text", text: "hi" }],
|
|
406
|
-
createdAt: new Date(0),
|
|
407
|
-
attachments: [],
|
|
408
|
-
metadata: { custom: {} },
|
|
409
|
-
} as unknown as ExternalThreadMessage,
|
|
410
|
-
],
|
|
411
|
-
isRunning: false,
|
|
412
|
-
onEdit: vi.fn(),
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
expect(() => aui().thread.message({ id: "u1" }).composer().send()).toThrow(
|
|
416
|
-
"Composer is not available",
|
|
417
|
-
);
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
it("throws on beginEdit when the runtime has no edit handler", () => {
|
|
421
|
-
const { aui } = renderThread({
|
|
422
|
-
messages: [
|
|
423
|
-
{
|
|
424
|
-
id: "u1",
|
|
425
|
-
role: "user",
|
|
426
|
-
content: [{ type: "text", text: "hi" }],
|
|
427
|
-
createdAt: new Date(0),
|
|
428
|
-
attachments: [],
|
|
429
|
-
metadata: { custom: {} },
|
|
430
|
-
} as unknown as ExternalThreadMessage,
|
|
431
|
-
],
|
|
432
|
-
isRunning: false,
|
|
433
|
-
queue: {
|
|
434
|
-
items: [],
|
|
435
|
-
steerItems: [],
|
|
436
|
-
enqueue: vi.fn(),
|
|
437
|
-
steer: vi.fn(),
|
|
438
|
-
move: vi.fn(),
|
|
439
|
-
edit: vi.fn(),
|
|
440
|
-
remove: vi.fn(),
|
|
441
|
-
},
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
expect(() =>
|
|
445
|
-
aui().thread.message({ id: "u1" }).composer().beginEdit(),
|
|
446
|
-
).toThrow("Runtime does not support editing.");
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it("still refuses to send an empty composer synchronously after a send", async () => {
|
|
450
|
-
const onNew = vi.fn();
|
|
451
|
-
const { aui } = renderThread({ messages: [], isRunning: false, onNew });
|
|
452
|
-
|
|
453
|
-
aui().thread.composer().send();
|
|
454
|
-
aui().thread.composer().setText("first");
|
|
455
|
-
aui().thread.composer().send();
|
|
456
|
-
aui().thread.composer().send();
|
|
457
|
-
|
|
458
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
459
|
-
expect(onNew).toHaveBeenCalledTimes(1);
|
|
460
|
-
});
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
describe("ExternalThread duplicate message ids", () => {
|
|
464
|
-
const userMessage = (id: string, text: string): ExternalThreadMessage =>
|
|
465
|
-
({
|
|
466
|
-
id,
|
|
467
|
-
role: "user",
|
|
468
|
-
content: [{ type: "text", text }],
|
|
469
|
-
createdAt: new Date(0),
|
|
470
|
-
metadata: { custom: {} },
|
|
471
|
-
}) as unknown as ExternalThreadMessage;
|
|
472
|
-
|
|
473
|
-
it("warns and keeps the last occurrence instead of throwing on a duplicate id", () => {
|
|
474
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
475
|
-
try {
|
|
476
|
-
const { aui } = renderThread({
|
|
477
|
-
messages: [
|
|
478
|
-
userMessage("u1", "hi"),
|
|
479
|
-
userMessage("dup", "stale"),
|
|
480
|
-
userMessage("dup", "fresh"),
|
|
481
|
-
],
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
const state = aui().thread.getState();
|
|
485
|
-
expect(state.messages.map((m) => m.id)).toEqual(["u1", "dup"]);
|
|
486
|
-
|
|
487
|
-
const dup = aui().thread.message({ id: "dup" }).getState();
|
|
488
|
-
expect(dup.parts[0]).toMatchObject({ type: "text", text: "fresh" });
|
|
489
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"dup"'));
|
|
490
|
-
} finally {
|
|
491
|
-
warn.mockRestore();
|
|
492
|
-
}
|
|
493
|
-
});
|
|
494
|
-
});
|