@truefoundry/assistant-ui-runtime 0.1.0-rc.1
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/README.md +567 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +4440 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
- package/src/agentSessionModule.d.ts +25 -0
- package/src/agentSpec.ts +44 -0
- package/src/askUserQuestion.ts +37 -0
- package/src/attachmentAdapter.test.ts +56 -0
- package/src/attachmentAdapter.ts +58 -0
- package/src/bindDraftAgentSession.test.ts +55 -0
- package/src/bindDraftAgentSession.ts +66 -0
- package/src/buildEditedUserMessageContent.test.ts +61 -0
- package/src/collectPending.test.ts +69 -0
- package/src/collectPending.ts +165 -0
- package/src/constants.ts +2 -0
- package/src/convertTurnMessages.test.ts +1991 -0
- package/src/convertTurnMessages.ts +1251 -0
- package/src/createSubAgent.ts +8 -0
- package/src/draftAgentConfig.test.ts +88 -0
- package/src/draftSessionBridge.ts +28 -0
- package/src/extractTurnUserText.ts +21 -0
- package/src/foldPeerThreads.test.ts +386 -0
- package/src/foldPeerThreads.ts +587 -0
- package/src/hooks.ts +123 -0
- package/src/index.ts +68 -0
- package/src/lastUserMessageText.ts +19 -0
- package/src/loadSessionSnapshot.test.ts +57 -0
- package/src/loadSessionSnapshot.ts +35 -0
- package/src/mcpAuth.ts +44 -0
- package/src/messageCustomMetadata.ts +37 -0
- package/src/modelMessageContent.ts +135 -0
- package/src/modelMessageImageContent.test.ts +109 -0
- package/src/modelMessageImageContent.ts +193 -0
- package/src/requiredActionInputs.test.ts +394 -0
- package/src/requiredActionInputs.ts +65 -0
- package/src/requiredActionsFromActiveUpdate.test.ts +95 -0
- package/src/sessionListStartTimestamp.ts +6 -0
- package/src/sessionSnapshot.ts +107 -0
- package/src/sessions.ts +36 -0
- package/src/streamTurn.test.ts +243 -0
- package/src/streamTurn.ts +119 -0
- package/src/toolApproval.test.ts +137 -0
- package/src/toolApproval.ts +459 -0
- package/src/toolResponse.test.ts +136 -0
- package/src/toolResponse.ts +427 -0
- package/src/truefoundryDraftThreadListAdapter.test.ts +140 -0
- package/src/truefoundryDraftThreadListAdapter.ts +63 -0
- package/src/truefoundryExtras.ts +45 -0
- package/src/truefoundryThreadListAdapter.test.ts +103 -0
- package/src/truefoundryThreadListAdapter.ts +59 -0
- package/src/turnEventHelpers.ts +98 -0
- package/src/turnStreamUpdate.ts +11 -0
- package/src/types.ts +92 -0
- package/src/useDraftAgentSpec.ts +173 -0
- package/src/useTrueFoundryAgentMessages.test.tsx +723 -0
- package/src/useTrueFoundryAgentMessages.ts +757 -0
- package/src/useTrueFoundryAgentRuntime.ts +268 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type { ThreadMessage } from "@assistant-ui/core";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { AgentSessionClient, Turn } from "truefoundry-gateway-sdk/agents";
|
|
6
|
+
import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
7
|
+
|
|
8
|
+
import { ROOT_THREAD_ID } from "./constants.js";
|
|
9
|
+
import { collectPendingToolResponses } from "./collectPending.js";
|
|
10
|
+
import { loadSessionSnapshot } from "./loadSessionSnapshot.js";
|
|
11
|
+
import {
|
|
12
|
+
buildRootAssistantContent,
|
|
13
|
+
ingestTurnEvent,
|
|
14
|
+
PeerThreadFoldState,
|
|
15
|
+
} from "./foldPeerThreads.js";
|
|
16
|
+
import {
|
|
17
|
+
createEmptySessionSnapshot,
|
|
18
|
+
replaceSessionSnapshot,
|
|
19
|
+
type SessionSnapshot,
|
|
20
|
+
} from "./sessionSnapshot.js";
|
|
21
|
+
import { getSession } from "./sessions.js";
|
|
22
|
+
import { resumeTurnStream, streamTurnContent } from "./streamTurn.js";
|
|
23
|
+
import {
|
|
24
|
+
messageHasPendingApprovals,
|
|
25
|
+
TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY,
|
|
26
|
+
} from "./toolApproval.js";
|
|
27
|
+
import {
|
|
28
|
+
messageHasPendingResponses,
|
|
29
|
+
TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
|
|
30
|
+
toolResponseMessageCustom,
|
|
31
|
+
toolResponseStatus,
|
|
32
|
+
} from "./toolResponse.js";
|
|
33
|
+
import { useTrueFoundryAgentMessages } from "./useTrueFoundryAgentMessages.js";
|
|
34
|
+
|
|
35
|
+
vi.mock("./sessions.js", () => ({
|
|
36
|
+
getSession: vi.fn(),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("./loadSessionSnapshot.js", () => ({
|
|
40
|
+
loadSessionSnapshot: vi.fn(),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("./streamTurn.js", () => ({
|
|
44
|
+
streamTurnContent: vi.fn(),
|
|
45
|
+
resumeTurnStream: vi.fn(),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock("./convertTurnMessages.js", async (importOriginal) => {
|
|
49
|
+
const actual = await importOriginal<typeof import("./convertTurnMessages.js")>();
|
|
50
|
+
return actual;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const mockClient = {} as AgentSessionClient;
|
|
54
|
+
|
|
55
|
+
function snapshotWithAssistantMessage(
|
|
56
|
+
message: Extract<ThreadMessage, { role: "assistant" }>,
|
|
57
|
+
extra?: Partial<SessionSnapshot>,
|
|
58
|
+
): SessionSnapshot {
|
|
59
|
+
const turnId = message.id.replace(/-assistant$/, "");
|
|
60
|
+
return replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
61
|
+
activeStream: {
|
|
62
|
+
turnId,
|
|
63
|
+
isContinuation: false,
|
|
64
|
+
update: {
|
|
65
|
+
content: [...message.content],
|
|
66
|
+
status: message.status,
|
|
67
|
+
metadata: { custom: message.metadata.custom },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
...extra,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function snapshotWithUserTurn(userText: string): SessionSnapshot {
|
|
75
|
+
const createdAt = new Date().toISOString();
|
|
76
|
+
return replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
77
|
+
turns: [
|
|
78
|
+
{
|
|
79
|
+
id: "turn-1",
|
|
80
|
+
userText,
|
|
81
|
+
createdAt,
|
|
82
|
+
state: {
|
|
83
|
+
status: "done",
|
|
84
|
+
requiredActions: [],
|
|
85
|
+
completedAt: createdAt,
|
|
86
|
+
},
|
|
87
|
+
input: [{ type: "user.message", content: userText }],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function snapshotWithAskUserPendingInFold(): SessionSnapshot {
|
|
94
|
+
const fold = new PeerThreadFoldState();
|
|
95
|
+
const turnId = "turn-ask";
|
|
96
|
+
|
|
97
|
+
ingestTurnEvent(fold, {
|
|
98
|
+
type: "model.message",
|
|
99
|
+
id: "model-1",
|
|
100
|
+
createdAt: new Date().toISOString(),
|
|
101
|
+
threadId: ROOT_THREAD_ID,
|
|
102
|
+
toolCalls: [
|
|
103
|
+
{
|
|
104
|
+
id: "question-1",
|
|
105
|
+
type: "function",
|
|
106
|
+
function: {
|
|
107
|
+
name: "ask_user_question",
|
|
108
|
+
arguments: JSON.stringify({
|
|
109
|
+
question: "Pick one",
|
|
110
|
+
options: ["A", "B"],
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
toolInfo: { type: "truefoundry-system", name: "ask_user_question" },
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
ingestTurnEvent(fold, {
|
|
119
|
+
type: "tool.response_required",
|
|
120
|
+
id: "resp-req-1",
|
|
121
|
+
createdAt: new Date().toISOString(),
|
|
122
|
+
threadId: ROOT_THREAD_ID,
|
|
123
|
+
toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const content = buildRootAssistantContent(fold);
|
|
127
|
+
|
|
128
|
+
return replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
129
|
+
fold,
|
|
130
|
+
pendingUser: {
|
|
131
|
+
turnId,
|
|
132
|
+
content: "ask me a question",
|
|
133
|
+
createdAt: new Date(),
|
|
134
|
+
},
|
|
135
|
+
activeStream: {
|
|
136
|
+
turnId,
|
|
137
|
+
isContinuation: false,
|
|
138
|
+
streamComplete: true,
|
|
139
|
+
update: {
|
|
140
|
+
content,
|
|
141
|
+
status: toolResponseStatus(),
|
|
142
|
+
metadata: { custom: toolResponseMessageCustom(ROOT_THREAD_ID) },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assistantMessageWithPendingApproval() {
|
|
149
|
+
return {
|
|
150
|
+
id: "turn-1-assistant",
|
|
151
|
+
role: "assistant" as const,
|
|
152
|
+
content: [
|
|
153
|
+
{
|
|
154
|
+
type: "tool-call" as const,
|
|
155
|
+
toolCallId: "approval-1",
|
|
156
|
+
toolName: "bash",
|
|
157
|
+
args: {},
|
|
158
|
+
argsText: "{}",
|
|
159
|
+
approval: { id: "approval-1" },
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
status: { type: "requires-action" as const, reason: "tool-calls" as const },
|
|
163
|
+
createdAt: new Date(),
|
|
164
|
+
metadata: {
|
|
165
|
+
unstable_state: null,
|
|
166
|
+
unstable_annotations: [],
|
|
167
|
+
unstable_data: [],
|
|
168
|
+
steps: [],
|
|
169
|
+
custom: { [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID },
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assistantMessageWithPendingApprovalAndResponse() {
|
|
175
|
+
return {
|
|
176
|
+
id: "turn-1-assistant",
|
|
177
|
+
role: "assistant" as const,
|
|
178
|
+
content: [
|
|
179
|
+
{
|
|
180
|
+
type: "tool-call" as const,
|
|
181
|
+
toolCallId: "approval-1",
|
|
182
|
+
toolName: "bash",
|
|
183
|
+
args: {},
|
|
184
|
+
argsText: "{}",
|
|
185
|
+
approval: { id: "approval-1" },
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
type: "tool-call" as const,
|
|
189
|
+
toolCallId: "question-1",
|
|
190
|
+
toolName: "ask_user_question",
|
|
191
|
+
args: {},
|
|
192
|
+
argsText: "{}",
|
|
193
|
+
interrupt: {
|
|
194
|
+
type: "human" as const,
|
|
195
|
+
payload: { question: "Pick one", options: ["A", "B"] },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
status: { type: "requires-action" as const, reason: "tool-calls" as const },
|
|
200
|
+
createdAt: new Date(),
|
|
201
|
+
metadata: {
|
|
202
|
+
unstable_state: null,
|
|
203
|
+
unstable_annotations: [],
|
|
204
|
+
unstable_data: [],
|
|
205
|
+
steps: [],
|
|
206
|
+
custom: {
|
|
207
|
+
[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID,
|
|
208
|
+
[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assistantMessageWithMultiThreadPendingActions() {
|
|
215
|
+
return {
|
|
216
|
+
id: "turn-1-assistant",
|
|
217
|
+
role: "assistant" as const,
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: "tool-call" as const,
|
|
221
|
+
toolCallId: "spawn-1",
|
|
222
|
+
toolName: "create_sub_agent",
|
|
223
|
+
args: {},
|
|
224
|
+
argsText: "{}",
|
|
225
|
+
messages: [
|
|
226
|
+
{
|
|
227
|
+
id: "child-assistant",
|
|
228
|
+
role: "assistant" as const,
|
|
229
|
+
content: [
|
|
230
|
+
{
|
|
231
|
+
type: "tool-call" as const,
|
|
232
|
+
toolCallId: "question-sub",
|
|
233
|
+
toolName: "ask_user_question",
|
|
234
|
+
args: {},
|
|
235
|
+
argsText: "{}",
|
|
236
|
+
interrupt: {
|
|
237
|
+
type: "human" as const,
|
|
238
|
+
payload: { question: "Sub?" },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
status: {
|
|
243
|
+
type: "requires-action" as const,
|
|
244
|
+
reason: "tool-calls" as const,
|
|
245
|
+
},
|
|
246
|
+
createdAt: new Date(),
|
|
247
|
+
metadata: {
|
|
248
|
+
unstable_state: null,
|
|
249
|
+
unstable_annotations: [],
|
|
250
|
+
unstable_data: [],
|
|
251
|
+
steps: [],
|
|
252
|
+
custom: { [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: "child-1" },
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
type: "tool-call" as const,
|
|
259
|
+
toolCallId: "approval-root",
|
|
260
|
+
toolName: "bash",
|
|
261
|
+
args: {},
|
|
262
|
+
argsText: "{}",
|
|
263
|
+
approval: { id: "approval-root" },
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
status: { type: "requires-action" as const, reason: "tool-calls" as const },
|
|
267
|
+
createdAt: new Date(),
|
|
268
|
+
metadata: {
|
|
269
|
+
unstable_state: null,
|
|
270
|
+
unstable_annotations: [],
|
|
271
|
+
unstable_data: [],
|
|
272
|
+
steps: [],
|
|
273
|
+
custom: { [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID },
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function* singleUpdateStream() {
|
|
279
|
+
yield { content: [{ type: "text" as const, text: "streamed reply" }] };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
describe("useTrueFoundryAgentMessages", () => {
|
|
283
|
+
beforeEach(() => {
|
|
284
|
+
vi.clearAllMocks();
|
|
285
|
+
vi.mocked(getSession).mockResolvedValue({} as never);
|
|
286
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(createEmptySessionSnapshot());
|
|
287
|
+
vi.mocked(streamTurnContent).mockReturnValue(singleUpdateStream());
|
|
288
|
+
vi.mocked(resumeTurnStream).mockReturnValue(singleUpdateStream());
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("clears messages when sessionId is undefined", async () => {
|
|
292
|
+
const { result } = renderHook(() =>
|
|
293
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: undefined }),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
297
|
+
expect(result.current.messages).toEqual([]);
|
|
298
|
+
expect(getSession).not.toHaveBeenCalled();
|
|
299
|
+
expect(loadSessionSnapshot).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("sendTurn lazily initializes a session when sessionId is undefined", async () => {
|
|
303
|
+
const initializeSession = vi.fn().mockResolvedValue({
|
|
304
|
+
remoteId: "session-new",
|
|
305
|
+
externalId: undefined,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const { result } = renderHook(() =>
|
|
309
|
+
useTrueFoundryAgentMessages({
|
|
310
|
+
client: mockClient,
|
|
311
|
+
sessionId: undefined,
|
|
312
|
+
initializeSession,
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
317
|
+
|
|
318
|
+
await act(async () => {
|
|
319
|
+
await result.current.sendTurn({ userMessage: "hello there" });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(initializeSession).toHaveBeenCalledOnce();
|
|
323
|
+
expect(getSession).toHaveBeenCalledWith(mockClient, "session-new");
|
|
324
|
+
expect(streamTurnContent).toHaveBeenCalled();
|
|
325
|
+
expect(loadSessionSnapshot).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("loads converted session history on mount", async () => {
|
|
329
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
330
|
+
snapshotWithUserTurn("hello"),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const { result } = renderHook(() =>
|
|
334
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
338
|
+
expect(loadSessionSnapshot).toHaveBeenCalledWith(mockClient, "session-1");
|
|
339
|
+
expect(result.current.messages).toHaveLength(1);
|
|
340
|
+
expect(result.current.messages[0]?.role).toBe("user");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("resumes a running turn after load", async () => {
|
|
344
|
+
const runningTurn = { id: "turn-running" } as Turn;
|
|
345
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
346
|
+
replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
347
|
+
turns: [
|
|
348
|
+
{
|
|
349
|
+
id: runningTurn.id,
|
|
350
|
+
createdAt: new Date().toISOString(),
|
|
351
|
+
state: { status: "running" },
|
|
352
|
+
input: [],
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
runningTurn,
|
|
356
|
+
unstable_resume: true,
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const { result } = renderHook(() =>
|
|
361
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
await waitFor(() => expect(result.current.isRunning).toBe(false));
|
|
365
|
+
expect(resumeTurnStream).toHaveBeenCalled();
|
|
366
|
+
await waitFor(() =>
|
|
367
|
+
expect(result.current.messages.at(-1)).toMatchObject({
|
|
368
|
+
role: "assistant",
|
|
369
|
+
content: [{ type: "text", text: "streamed reply" }],
|
|
370
|
+
status: { type: "complete", reason: "stop" },
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("sendTurn appends a user message and streams the assistant reply", async () => {
|
|
376
|
+
const { result } = renderHook(() =>
|
|
377
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
378
|
+
);
|
|
379
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
380
|
+
|
|
381
|
+
await act(async () => {
|
|
382
|
+
await result.current.sendTurn({ userMessage: "hello there" });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
expect(streamTurnContent).toHaveBeenCalled();
|
|
386
|
+
expect(result.current.messages).toHaveLength(2);
|
|
387
|
+
expect(result.current.messages[0]).toMatchObject({
|
|
388
|
+
role: "user",
|
|
389
|
+
content: [{ type: "text", text: "hello there" }],
|
|
390
|
+
});
|
|
391
|
+
expect(result.current.messages[1]).toMatchObject({
|
|
392
|
+
role: "assistant",
|
|
393
|
+
content: [{ type: "text", text: "streamed reply" }],
|
|
394
|
+
status: { type: "complete", reason: "stop" },
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("carries a streamed sandboxId through commit so it survives after the stream completes", async () => {
|
|
399
|
+
vi.mocked(streamTurnContent).mockReturnValue(
|
|
400
|
+
(async function* () {
|
|
401
|
+
yield {
|
|
402
|
+
content: [{ type: "text" as const, text: "streamed reply" }],
|
|
403
|
+
metadata: { custom: { sandboxId: "sbx-123" } },
|
|
404
|
+
};
|
|
405
|
+
})(),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const { result } = renderHook(() =>
|
|
409
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
410
|
+
);
|
|
411
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
412
|
+
|
|
413
|
+
await act(async () => {
|
|
414
|
+
await result.current.sendTurn({ userMessage: "hello there" });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
expect(result.current.messages[1]).toMatchObject({
|
|
418
|
+
role: "assistant",
|
|
419
|
+
metadata: { custom: { sandboxId: "sbx-123" } },
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("sendTurn with approvals streams a continuation without adding a user message", async () => {
|
|
424
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
425
|
+
snapshotWithAssistantMessage(assistantMessageWithPendingApproval()),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const { result } = renderHook(() =>
|
|
429
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
430
|
+
);
|
|
431
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
|
432
|
+
|
|
433
|
+
await act(async () => {
|
|
434
|
+
await result.current.sendTurn({
|
|
435
|
+
inputs: [
|
|
436
|
+
{
|
|
437
|
+
type: "user.tool_approval",
|
|
438
|
+
threadId: ROOT_THREAD_ID,
|
|
439
|
+
toolCallId: "approval-1",
|
|
440
|
+
approval: { status: "allow" },
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(streamTurnContent).toHaveBeenCalledWith(
|
|
447
|
+
expect.anything(),
|
|
448
|
+
expect.any(PeerThreadFoldState),
|
|
449
|
+
{
|
|
450
|
+
inputs: [
|
|
451
|
+
{
|
|
452
|
+
type: "user.tool_approval",
|
|
453
|
+
threadId: ROOT_THREAD_ID,
|
|
454
|
+
toolCallId: "approval-1",
|
|
455
|
+
approval: { status: "allow" },
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
},
|
|
459
|
+
expect.any(AbortSignal),
|
|
460
|
+
expect.any(Array),
|
|
461
|
+
);
|
|
462
|
+
expect(result.current.messages).toHaveLength(1);
|
|
463
|
+
expect(result.current.messages[0]?.role).toBe("assistant");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("respondToToolApproval records approval decisions on the pending tool call", async () => {
|
|
467
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
468
|
+
snapshotWithAssistantMessage(assistantMessageWithPendingApproval()),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const { result } = renderHook(() =>
|
|
472
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
473
|
+
);
|
|
474
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
|
475
|
+
|
|
476
|
+
await act(async () => {
|
|
477
|
+
result.current.respondToToolApproval({
|
|
478
|
+
approvalId: "approval-1",
|
|
479
|
+
approved: true,
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
await waitFor(() => {
|
|
484
|
+
const assistant = result.current.messages[0];
|
|
485
|
+
expect(assistant?.role).toBe("assistant");
|
|
486
|
+
if (assistant?.role !== "assistant") {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
expect(messageHasPendingApprovals(assistant)).toBe(false);
|
|
490
|
+
const toolCall = assistant.content[0];
|
|
491
|
+
if (toolCall?.type !== "tool-call") {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
expect(toolCall.approval?.approved).toBe(true);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
expect(streamTurnContent).toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("respondToToolApproval sends combined inputs only after responses are answered", async () => {
|
|
501
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
502
|
+
snapshotWithAssistantMessage(assistantMessageWithPendingApprovalAndResponse()),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const { result } = renderHook(() =>
|
|
506
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
507
|
+
);
|
|
508
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
|
509
|
+
|
|
510
|
+
await act(async () => {
|
|
511
|
+
result.current.respondToToolApproval({
|
|
512
|
+
approvalId: "approval-1",
|
|
513
|
+
approved: true,
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
expect(streamTurnContent).not.toHaveBeenCalled();
|
|
518
|
+
|
|
519
|
+
await act(async () => {
|
|
520
|
+
result.current.respondToToolResponse({
|
|
521
|
+
toolCallId: "question-1",
|
|
522
|
+
content: "A",
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
await waitFor(() => expect(streamTurnContent).toHaveBeenCalled());
|
|
527
|
+
expect(streamTurnContent).toHaveBeenCalledWith(
|
|
528
|
+
expect.anything(),
|
|
529
|
+
expect.any(PeerThreadFoldState),
|
|
530
|
+
{
|
|
531
|
+
inputs: [
|
|
532
|
+
{
|
|
533
|
+
type: "user.tool_approval",
|
|
534
|
+
threadId: ROOT_THREAD_ID,
|
|
535
|
+
toolCallId: "approval-1",
|
|
536
|
+
approval: { status: "allow" },
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
type: "user.tool_response",
|
|
540
|
+
threadId: ROOT_THREAD_ID,
|
|
541
|
+
toolCallId: "question-1",
|
|
542
|
+
content: "A",
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
expect.any(AbortSignal),
|
|
547
|
+
expect.any(Array),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const assistant = result.current.messages[0];
|
|
551
|
+
expect(assistant?.role).toBe("assistant");
|
|
552
|
+
if (assistant?.role !== "assistant") {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
expect(messageHasPendingApprovals(assistant)).toBe(false);
|
|
556
|
+
expect(messageHasPendingResponses(assistant)).toBe(false);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("keeps ask-user resolved after respond and stream completion clears overlay", async () => {
|
|
560
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
561
|
+
snapshotWithAskUserPendingInFold(),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const { result } = renderHook(() =>
|
|
565
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
566
|
+
);
|
|
567
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
|
568
|
+
expect(collectPendingToolResponses(result.current.messages)).toHaveLength(1);
|
|
569
|
+
|
|
570
|
+
await act(async () => {
|
|
571
|
+
result.current.respondToToolResponse({
|
|
572
|
+
toolCallId: "question-1",
|
|
573
|
+
content: "A",
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
await waitFor(() => expect(streamTurnContent).toHaveBeenCalled());
|
|
578
|
+
await waitFor(() => expect(result.current.isRunning).toBe(false));
|
|
579
|
+
|
|
580
|
+
expect(collectPendingToolResponses(result.current.messages)).toHaveLength(0);
|
|
581
|
+
const assistant = result.current.messages.find((m) => m.role === "assistant");
|
|
582
|
+
expect(assistant).toBeDefined();
|
|
583
|
+
expect(messageHasPendingResponses(assistant)).toBe(false);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe("batched resume invariant", () => {
|
|
587
|
+
it("issues exactly one prepareTurn input batch across root and sub-agent threads", async () => {
|
|
588
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
589
|
+
snapshotWithAssistantMessage(assistantMessageWithMultiThreadPendingActions()),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const { result } = renderHook(() =>
|
|
593
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
594
|
+
);
|
|
595
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
|
596
|
+
|
|
597
|
+
await act(async () => {
|
|
598
|
+
result.current.respondToToolResponse({
|
|
599
|
+
toolCallId: "question-sub",
|
|
600
|
+
content: "sub-answer",
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
expect(streamTurnContent).not.toHaveBeenCalled();
|
|
604
|
+
|
|
605
|
+
await act(async () => {
|
|
606
|
+
result.current.respondToToolApproval({
|
|
607
|
+
approvalId: "approval-root",
|
|
608
|
+
approved: true,
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
await waitFor(() => expect(streamTurnContent).toHaveBeenCalledTimes(1));
|
|
613
|
+
expect(streamTurnContent).toHaveBeenCalledWith(
|
|
614
|
+
expect.anything(),
|
|
615
|
+
expect.any(PeerThreadFoldState),
|
|
616
|
+
{
|
|
617
|
+
inputs: [
|
|
618
|
+
{
|
|
619
|
+
type: "user.tool_approval",
|
|
620
|
+
threadId: ROOT_THREAD_ID,
|
|
621
|
+
toolCallId: "approval-root",
|
|
622
|
+
approval: { status: "allow" },
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
type: "user.tool_response",
|
|
626
|
+
threadId: "child-1",
|
|
627
|
+
toolCallId: "question-sub",
|
|
628
|
+
content: "sub-answer",
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
},
|
|
632
|
+
expect.any(AbortSignal),
|
|
633
|
+
expect.any(Array),
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("does not resume after the first resolved action when another is still pending", async () => {
|
|
638
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
639
|
+
snapshotWithAssistantMessage(assistantMessageWithPendingApprovalAndResponse()),
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const { result } = renderHook(() =>
|
|
643
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
644
|
+
);
|
|
645
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
|
646
|
+
|
|
647
|
+
await act(async () => {
|
|
648
|
+
result.current.respondToToolResponse({
|
|
649
|
+
toolCallId: "question-1",
|
|
650
|
+
content: "A",
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
expect(streamTurnContent).not.toHaveBeenCalled();
|
|
655
|
+
expect(messageHasPendingApprovals(result.current.messages[0]!)).toBe(true);
|
|
656
|
+
expect(messageHasPendingResponses(result.current.messages[0]!)).toBe(false);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("cancel drains the stream gracefully and calls session.cancel", async () => {
|
|
661
|
+
let resolveStream: (() => void) | undefined;
|
|
662
|
+
vi.mocked(streamTurnContent).mockReturnValue(
|
|
663
|
+
(async function* () {
|
|
664
|
+
yield { content: [{ type: "text" as const, text: "partial" }] };
|
|
665
|
+
await new Promise<void>((resolve) => {
|
|
666
|
+
resolveStream = resolve;
|
|
667
|
+
});
|
|
668
|
+
})(),
|
|
669
|
+
);
|
|
670
|
+
// session.cancel() makes the backend close the SSE stream gracefully,
|
|
671
|
+
// which ends the active iterator on its own.
|
|
672
|
+
const cancel = vi.fn().mockImplementation(async () => {
|
|
673
|
+
resolveStream?.();
|
|
674
|
+
});
|
|
675
|
+
vi.mocked(getSession).mockResolvedValue({ cancel } as never);
|
|
676
|
+
|
|
677
|
+
const { result } = renderHook(() =>
|
|
678
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
679
|
+
);
|
|
680
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
681
|
+
|
|
682
|
+
let sendPromise: Promise<void> | undefined;
|
|
683
|
+
await act(async () => {
|
|
684
|
+
sendPromise = result.current.sendTurn({ userMessage: "hello" });
|
|
685
|
+
});
|
|
686
|
+
await waitFor(() => expect(result.current.isRunning).toBe(true));
|
|
687
|
+
|
|
688
|
+
await act(async () => {
|
|
689
|
+
await result.current.cancel();
|
|
690
|
+
await sendPromise;
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
expect(cancel).toHaveBeenCalled();
|
|
694
|
+
expect(result.current.isRunning).toBe(false);
|
|
695
|
+
// No reconcile is triggered by cancel; the session was only loaded once
|
|
696
|
+
// on mount and reconciles against the event log on the next page load.
|
|
697
|
+
expect(loadSessionSnapshot).toHaveBeenCalledTimes(1);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("cancel resolves the session through the draft gateway in draft mode", async () => {
|
|
701
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
702
|
+
vi.mocked(getSession).mockResolvedValue({ cancel } as never);
|
|
703
|
+
const draftGateway = {} as TrueFoundryGateway;
|
|
704
|
+
|
|
705
|
+
const { result } = renderHook(() =>
|
|
706
|
+
useTrueFoundryAgentMessages({
|
|
707
|
+
client: mockClient,
|
|
708
|
+
sessionId: "draft-session-1",
|
|
709
|
+
draftGateway,
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
713
|
+
|
|
714
|
+
await act(async () => {
|
|
715
|
+
await result.current.cancel();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
expect(cancel).toHaveBeenCalled();
|
|
719
|
+
expect(getSession).toHaveBeenCalledWith(mockClient, "draft-session-1", {
|
|
720
|
+
draftGateway,
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
});
|