Skip to content

Commit 267ef71

Browse files
committed
test(webapp): suspend/resume, upgrade deferral, and multi-hop continuation legs
Builds on the waitpoint backend to add the run-lifecycle scenarios it unlocks: a HITL tool approval that crosses a suspend/resume boundary (the answer arrives after the run suspends on the idle waitpoint), a run that suspends and resumes across multiple turns, a requestUpgrade that defers the message so the continuation run processes it, and a three-hop endRun chain that restores history across each continuation.
1 parent 7d68f71 commit 267ef71

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

apps/webapp/test/helpers/testChatAgent.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,30 @@ export const testUpgradeChatAgent = chat.agent({
137137
},
138138
});
139139

140+
/**
141+
* Requests an upgrade only on the fresh (non-continuation) run. The first run
142+
* defers the message via `upgrade-required`; the continuation run treats it as
143+
* the "new version" and processes the deferred message instead of upgrading
144+
* again (which would loop).
145+
*/
146+
export const testUpgradeOnceChatAgent = chat.agent({
147+
id: "e2e-test-chat-upgrade-once",
148+
idleTimeoutInSeconds: 2,
149+
preloadIdleTimeoutInSeconds: 2,
150+
onTurnStart: async ({ continuation }) => {
151+
if (!continuation) {
152+
chat.requestUpgrade();
153+
}
154+
},
155+
run: async ({ messages, signal }) => {
156+
const model = locals.get(testChatModelLocal);
157+
if (!model) {
158+
throw new Error("test model not injected via locals");
159+
}
160+
return streamText({ model, messages, abortSignal: signal });
161+
},
162+
});
163+
140164
/**
141165
* A tool with a server-side `execute`: the agent runs it automatically and
142166
* feeds the result back to the model, so a single turn covers the whole
@@ -191,6 +215,29 @@ export const testHitlChatAgent = chat.agent({
191215
},
192216
});
193217

218+
/**
219+
* Same HITL tool but with a 1-second idle window, so the run suspends on the
220+
* waitpoint while waiting for the human's tool answer. Exercises a HITL
221+
* round-trip that crosses a suspend/resume boundary.
222+
*/
223+
export const testHitlIdleChatAgent = chat.agent({
224+
id: "e2e-test-chat-hitl-idle",
225+
idleTimeoutInSeconds: 1,
226+
preloadIdleTimeoutInSeconds: 1,
227+
run: async ({ messages, signal }) => {
228+
const model = locals.get(testChatModelLocal);
229+
if (!model) {
230+
throw new Error("test model not injected via locals");
231+
}
232+
return streamText({
233+
model,
234+
messages,
235+
tools: { askUser: askUserTool },
236+
abortSignal: signal,
237+
});
238+
},
239+
});
240+
194241
/**
195242
* A tool that both executes and requires approval. The model's call parks on
196243
* an approval request; the client approves (or denies) before the `execute`

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

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ import {
3434
testChatModelLocal,
3535
testEndRunChatAgent,
3636
testHitlChatAgent,
37+
testHitlIdleChatAgent,
3738
testIdleChatAgent,
3839
testPlainChatAgent,
3940
testToolChatAgent,
4041
testUpgradeChatAgent,
42+
testUpgradeOnceChatAgent,
4143
} from "./helpers/testChatAgent";
4244

4345
async function waitFor(predicate: () => boolean, maxMs: number): Promise<void> {
@@ -1116,4 +1118,236 @@ describe("session agent e2e (real chat.agent loop)", () => {
11161118
await agent.close();
11171119
}
11181120
});
1121+
1122+
it("EA16: a HITL tool approval survives a suspend/resume boundary", async () => {
1123+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(testHitlIdleChatAgent.id);
1124+
const toolCallId = "tc_ask_suspend";
1125+
const agent = runRealChatAgent({
1126+
agentId: testHitlIdleChatAgent.id,
1127+
baseUrl,
1128+
addressingKey,
1129+
secretKey: apiKey,
1130+
model: toolCallThenText({
1131+
toolName: "askUser",
1132+
toolCallId,
1133+
input: { question: "what color?" },
1134+
finalText: "blue it is",
1135+
}),
1136+
modelLocal: testChatModelLocal,
1137+
});
1138+
1139+
try {
1140+
await appendInput({
1141+
baseUrl,
1142+
addressingKey,
1143+
token,
1144+
partId: "u1",
1145+
body: submitBody(addressingKey, userMessage("pick a color", "u1")),
1146+
});
1147+
const turn1 = await collectSessionOut({
1148+
baseUrl,
1149+
addressingKey,
1150+
token,
1151+
until: (p) => p.some(isTurnComplete),
1152+
maxMs: 30_000,
1153+
});
1154+
expect(joinChunks(turn1.parts), "turn 1 parks on the tool call").toContain("askUser");
1155+
1156+
await new Promise((r) => setTimeout(r, 2500));
1157+
1158+
const answer = {
1159+
id: "a-answer",
1160+
role: "assistant",
1161+
parts: [
1162+
{
1163+
type: "tool-askUser",
1164+
toolCallId,
1165+
state: "output-available",
1166+
input: { question: "what color?" },
1167+
output: { color: "blue" },
1168+
},
1169+
],
1170+
};
1171+
await appendInput({
1172+
baseUrl,
1173+
addressingKey,
1174+
token,
1175+
partId: "u2",
1176+
body: submitBody(addressingKey, answer),
1177+
});
1178+
const turn2 = await collectSessionOut({
1179+
baseUrl,
1180+
addressingKey,
1181+
token,
1182+
until: (p) => joinChunks(p).includes("blue it is"),
1183+
maxMs: 30_000,
1184+
});
1185+
expect(
1186+
joinChunks(turn2.parts),
1187+
"the answer sent after the idle window resumed the suspended run"
1188+
).toContain("blue it is");
1189+
} finally {
1190+
await agent.close();
1191+
}
1192+
});
1193+
1194+
it("EA17: the run suspends and resumes across multiple turns", async () => {
1195+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(testIdleChatAgent.id);
1196+
const replies = ["reply-one", "reply-two", "reply-three"];
1197+
const agent = runRealChatAgent({
1198+
agentId: testIdleChatAgent.id,
1199+
baseUrl,
1200+
addressingKey,
1201+
secretKey: apiKey,
1202+
model: sequenceModel(replies),
1203+
modelLocal: testChatModelLocal,
1204+
});
1205+
1206+
try {
1207+
for (let i = 0; i < replies.length; i++) {
1208+
await new Promise((r) => setTimeout(r, i === 0 ? 1500 : 2000));
1209+
await appendInput({
1210+
baseUrl,
1211+
addressingKey,
1212+
token,
1213+
partId: `u${i + 1}`,
1214+
body: submitBody(addressingKey, userMessage(`turn ${i + 1}`, `u${i + 1}`)),
1215+
});
1216+
const { parts } = await collectSessionOut({
1217+
baseUrl,
1218+
addressingKey,
1219+
token,
1220+
until: (p) => joinChunks(p).includes(replies[i]!),
1221+
maxMs: 30_000,
1222+
});
1223+
expect(
1224+
joinChunks(parts),
1225+
`turn ${i + 1} resumed from a suspend and produced its reply`
1226+
).toContain(replies[i]!);
1227+
}
1228+
} finally {
1229+
await agent.close();
1230+
}
1231+
});
1232+
1233+
it("EA18: requestUpgrade defers the message; the upgraded run processes it", async () => {
1234+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(
1235+
testUpgradeOnceChatAgent.id
1236+
);
1237+
const session = runChatAgentSession({
1238+
agentId: testUpgradeOnceChatAgent.id,
1239+
baseUrl,
1240+
addressingKey,
1241+
secretKey: apiKey,
1242+
model: echoModel(),
1243+
modelLocal: testChatModelLocal,
1244+
});
1245+
1246+
try {
1247+
await appendInput({
1248+
baseUrl,
1249+
addressingKey,
1250+
token,
1251+
partId: "u1",
1252+
body: submitBody(addressingKey, userMessage("DEFER-ME", "u1")),
1253+
});
1254+
const turn1 = await collectSessionOut({
1255+
baseUrl,
1256+
addressingKey,
1257+
token,
1258+
until: (p) => p.some(isUpgradeRequired),
1259+
maxMs: 30_000,
1260+
});
1261+
expect(
1262+
turn1.parts.some(isUpgradeRequired),
1263+
"the fresh run defers the message with upgrade-required"
1264+
).toBe(true);
1265+
1266+
await waitFor(() => session.runCount() >= 2, 10_000);
1267+
1268+
const { parts } = await collectSessionOut({
1269+
baseUrl,
1270+
addressingKey,
1271+
token,
1272+
until: (p) => joinChunks(p).includes("DEFER-ME"),
1273+
maxMs: 30_000,
1274+
});
1275+
expect(
1276+
joinChunks(parts),
1277+
"the continuation run processed the deferred message instead of upgrading again"
1278+
).toContain("DEFER-ME");
1279+
} finally {
1280+
await session.close();
1281+
}
1282+
});
1283+
1284+
it("EA19: endRun continuation restores history across multiple hops", async () => {
1285+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(testEndRunChatAgent.id);
1286+
const session = runChatAgentSession({
1287+
agentId: testEndRunChatAgent.id,
1288+
baseUrl,
1289+
addressingKey,
1290+
secretKey: apiKey,
1291+
model: echoModel(),
1292+
modelLocal: testChatModelLocal,
1293+
});
1294+
1295+
try {
1296+
await appendInput({
1297+
baseUrl,
1298+
addressingKey,
1299+
token,
1300+
partId: "u1",
1301+
body: submitBody(addressingKey, userMessage("HOP-MARKER", "u1")),
1302+
});
1303+
await collectSessionOut({
1304+
baseUrl,
1305+
addressingKey,
1306+
token,
1307+
until: (p) => p.some(isTurnComplete),
1308+
maxMs: 30_000,
1309+
});
1310+
1311+
await waitFor(() => session.runCount() >= 2, 10_000);
1312+
await appendInput({
1313+
baseUrl,
1314+
addressingKey,
1315+
token,
1316+
partId: "u2",
1317+
body: submitBody(addressingKey, userMessage("second hop", "u2")),
1318+
});
1319+
await collectSessionOut({
1320+
baseUrl,
1321+
addressingKey,
1322+
token,
1323+
until: (p) => joinChunks(p).includes("second hop"),
1324+
maxMs: 30_000,
1325+
});
1326+
1327+
await waitFor(() => session.runCount() >= 3, 10_000);
1328+
await appendInput({
1329+
baseUrl,
1330+
addressingKey,
1331+
token,
1332+
partId: "u3",
1333+
body: submitBody(addressingKey, userMessage("third hop", "u3")),
1334+
});
1335+
const { parts } = await collectSessionOut({
1336+
baseUrl,
1337+
addressingKey,
1338+
token,
1339+
until: (p) => joinChunks(p).includes("third hop"),
1340+
maxMs: 30_000,
1341+
});
1342+
1343+
const blob = joinChunks(parts);
1344+
expect(blob, "history from the first hop is restored two continuations later").toContain(
1345+
"HOP-MARKER"
1346+
);
1347+
expect(blob).toContain("third hop");
1348+
expect(session.runCount()).toBeGreaterThanOrEqual(3);
1349+
} finally {
1350+
await session.close();
1351+
}
1352+
});
11191353
});

0 commit comments

Comments
 (0)