Skip to content

Commit 31a76a7

Browse files
committed
test(webapp): agent e2e legs for regenerate, tool approval, and session isolation
1 parent d94a6a0 commit 31a76a7

2 files changed

Lines changed: 277 additions & 0 deletions

File tree

apps/webapp/test/helpers/testChatAgent.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,32 @@ export const testHitlChatAgent = chat.agent({
131131
});
132132
},
133133
});
134+
135+
/**
136+
* A tool that both executes and requires approval. The model's call parks on
137+
* an approval request; the client approves (or denies) before the `execute`
138+
* runs.
139+
*/
140+
const deleteResourceTool = tool({
141+
description: "Delete a resource. Requires human approval before running.",
142+
inputSchema: z.object({ resource: z.string() }),
143+
needsApproval: true,
144+
execute: async ({ resource }) => ({ deleted: resource }),
145+
});
146+
147+
export const testApprovalChatAgent = chat.agent({
148+
id: "e2e-test-chat-approval",
149+
run: async ({ messages, signal }) => {
150+
const model = locals.get(testChatModelLocal);
151+
if (!model) {
152+
throw new Error("test model not injected via locals");
153+
}
154+
return streamText({
155+
model,
156+
messages,
157+
tools: { deleteResource: deleteResourceTool },
158+
stopWhen: stepCountIs(5),
159+
abortSignal: signal,
160+
});
161+
},
162+
});

apps/webapp/test/session-agent.e2e.test.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from "./helpers/sessionStream";
2929
import { runRealChatAgent } from "./helpers/agentHarness";
3030
import {
31+
testApprovalChatAgent,
3132
testChatAgent,
3233
testChatModelLocal,
3334
testHitlChatAgent,
@@ -232,6 +233,43 @@ function joinChunks(parts: CollectedPart[]): string {
232233
.join("");
233234
}
234235

236+
/** A model that returns `texts[i]` on its i-th `doStream` call. */
237+
function sequenceModel(texts: string[]) {
238+
let idx = 0;
239+
return new MockLanguageModelV3({
240+
doStream: async () => {
241+
const text = texts[Math.min(idx, texts.length - 1)]!;
242+
idx++;
243+
const chunks = [
244+
{ type: "text-start", id: "t1" },
245+
{ type: "text-delta", id: "t1", delta: text },
246+
{ type: "text-end", id: "t1" },
247+
FINISH_STOP,
248+
];
249+
return { stream: simulateReadableStream({ chunks: chunks as never }) };
250+
},
251+
});
252+
}
253+
254+
function regenerateBody(addressingKey: string) {
255+
return JSON.stringify({
256+
kind: "message",
257+
payload: { chatId: addressingKey, trigger: "regenerate-message", metadata: {} },
258+
});
259+
}
260+
261+
function findApprovalRequest(
262+
parts: CollectedPart[]
263+
): { approvalId: string; toolCallId: string } | undefined {
264+
for (const p of parts) {
265+
const c = p.chunk as { type?: string; approvalId?: string; toolCallId?: string } | null;
266+
if (c && c.type === "tool-approval-request" && c.approvalId && c.toolCallId) {
267+
return { approvalId: c.approvalId, toolCallId: c.toolCallId };
268+
}
269+
}
270+
return undefined;
271+
}
272+
235273
describe("session agent e2e (real chat.agent loop)", () => {
236274
it("EA1: a real agent turn streams assistant text to .out", async () => {
237275
const { addressingKey, token, apiKey, baseUrl } = await setupSession();
@@ -717,4 +755,214 @@ describe("session agent e2e (real chat.agent loop)", () => {
717755
await secondRun.close();
718756
}
719757
});
758+
759+
it("EA10: regenerate re-runs the last user turn with a fresh model call", async () => {
760+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(testPlainChatAgent.id);
761+
const agent = runRealChatAgent({
762+
agentId: testPlainChatAgent.id,
763+
baseUrl,
764+
addressingKey,
765+
secretKey: apiKey,
766+
model: sequenceModel(["first-answer", "regenerated-answer"]),
767+
modelLocal: testChatModelLocal,
768+
});
769+
770+
try {
771+
await appendInput({
772+
baseUrl,
773+
addressingKey,
774+
token,
775+
partId: "u1",
776+
body: submitBody(addressingKey, userMessage("explain it", "u1")),
777+
});
778+
const first = await collectSessionOut({
779+
baseUrl,
780+
addressingKey,
781+
token,
782+
until: (p) => p.some(isTurnComplete),
783+
maxMs: 30_000,
784+
});
785+
expect(joinChunks(first.parts)).toContain("first-answer");
786+
787+
await appendInput({
788+
baseUrl,
789+
addressingKey,
790+
token,
791+
partId: "regen1",
792+
body: regenerateBody(addressingKey),
793+
});
794+
const second = await collectSessionOut({
795+
baseUrl,
796+
addressingKey,
797+
token,
798+
until: (p) => p.filter(isTurnComplete).length >= 2,
799+
maxMs: 30_000,
800+
});
801+
expect(joinChunks(second.parts), "the regenerated turn produced a fresh answer").toContain(
802+
"regenerated-answer"
803+
);
804+
} finally {
805+
await agent.close();
806+
}
807+
});
808+
809+
it("EA11: a needsApproval tool parks on an approval request and runs once approved", async () => {
810+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(testApprovalChatAgent.id);
811+
const toolCallId = "tc_delete_e2e";
812+
const agent = runRealChatAgent({
813+
agentId: testApprovalChatAgent.id,
814+
baseUrl,
815+
addressingKey,
816+
secretKey: apiKey,
817+
model: toolCallThenText({
818+
toolName: "deleteResource",
819+
toolCallId,
820+
input: { resource: "widget-1" },
821+
finalText: "deleted widget-1",
822+
}),
823+
modelLocal: testChatModelLocal,
824+
});
825+
826+
try {
827+
await appendInput({
828+
baseUrl,
829+
addressingKey,
830+
token,
831+
partId: "u1",
832+
body: submitBody(addressingKey, userMessage("delete widget-1", "u1")),
833+
});
834+
const turn1 = await collectSessionOut({
835+
baseUrl,
836+
addressingKey,
837+
token,
838+
until: (p) => p.some(isTurnComplete),
839+
maxMs: 30_000,
840+
});
841+
const approval = findApprovalRequest(turn1.parts);
842+
expect(approval, "turn 1 emits an approval request instead of executing").toBeTruthy();
843+
expect(joinChunks(turn1.parts), "the tool did not execute before approval").not.toContain(
844+
"deleted widget-1"
845+
);
846+
847+
const answer = {
848+
id: "a-approval",
849+
role: "assistant",
850+
parts: [
851+
{
852+
type: "tool-deleteResource",
853+
toolCallId: approval!.toolCallId,
854+
state: "approval-responded",
855+
approval: { id: approval!.approvalId, approved: true },
856+
},
857+
],
858+
};
859+
await appendInput({
860+
baseUrl,
861+
addressingKey,
862+
token,
863+
partId: "u2",
864+
body: submitBody(addressingKey, answer),
865+
});
866+
const turn2 = await collectSessionOut({
867+
baseUrl,
868+
addressingKey,
869+
token,
870+
until: (p) => p.filter(isTurnComplete).length >= 2,
871+
maxMs: 30_000,
872+
});
873+
expect(
874+
joinChunks(turn2.parts),
875+
"after approval the tool runs and the agent answers"
876+
).toContain("deleted widget-1");
877+
} finally {
878+
await agent.close();
879+
}
880+
});
881+
882+
it("EA12: two chats stay isolated - neither run bleeds into the other's .out", async () => {
883+
const chatA = await setupSession(testPlainChatAgent.id);
884+
const chatB = await setupSession(testPlainChatAgent.id);
885+
886+
const agentA = runRealChatAgent({
887+
agentId: testPlainChatAgent.id,
888+
baseUrl: chatA.baseUrl,
889+
addressingKey: chatA.addressingKey,
890+
secretKey: chatA.apiKey,
891+
model: textModel("answer-for-A"),
892+
modelLocal: testChatModelLocal,
893+
});
894+
try {
895+
await appendInput({
896+
baseUrl: chatA.baseUrl,
897+
addressingKey: chatA.addressingKey,
898+
token: chatA.token,
899+
partId: "a1",
900+
body: submitBody(chatA.addressingKey, userMessage("hi from A", "a1")),
901+
});
902+
await collectSessionOut({
903+
baseUrl: chatA.baseUrl,
904+
addressingKey: chatA.addressingKey,
905+
token: chatA.token,
906+
until: (p) => p.some(isTurnComplete),
907+
maxMs: 30_000,
908+
});
909+
} finally {
910+
await agentA.close();
911+
}
912+
913+
const agentB = runRealChatAgent({
914+
agentId: testPlainChatAgent.id,
915+
baseUrl: chatB.baseUrl,
916+
addressingKey: chatB.addressingKey,
917+
secretKey: chatB.apiKey,
918+
model: textModel("answer-for-B"),
919+
modelLocal: testChatModelLocal,
920+
});
921+
try {
922+
await appendInput({
923+
baseUrl: chatB.baseUrl,
924+
addressingKey: chatB.addressingKey,
925+
token: chatB.token,
926+
partId: "b1",
927+
body: submitBody(chatB.addressingKey, userMessage("hi from B", "b1")),
928+
});
929+
await collectSessionOut({
930+
baseUrl: chatB.baseUrl,
931+
addressingKey: chatB.addressingKey,
932+
token: chatB.token,
933+
until: (p) => p.some(isTurnComplete),
934+
maxMs: 30_000,
935+
});
936+
} finally {
937+
await agentB.close();
938+
}
939+
940+
const outA = joinChunks(
941+
(
942+
await collectSessionOut({
943+
baseUrl: chatA.baseUrl,
944+
addressingKey: chatA.addressingKey,
945+
token: chatA.token,
946+
until: (p) => p.some(isTurnComplete),
947+
maxMs: 15_000,
948+
})
949+
).parts
950+
);
951+
const outB = joinChunks(
952+
(
953+
await collectSessionOut({
954+
baseUrl: chatB.baseUrl,
955+
addressingKey: chatB.addressingKey,
956+
token: chatB.token,
957+
until: (p) => p.some(isTurnComplete),
958+
maxMs: 15_000,
959+
})
960+
).parts
961+
);
962+
963+
expect(outA).toContain("answer-for-A");
964+
expect(outA, "A's stream never sees B's output").not.toContain("answer-for-B");
965+
expect(outB).toContain("answer-for-B");
966+
expect(outB, "B's stream never sees A's output").not.toContain("answer-for-A");
967+
});
720968
});

0 commit comments

Comments
 (0)