Skip to content

Commit 7d68f71

Browse files
committed
test(core,webapp): in-process waitpoint backend for run-lifecycle e2e
Extends the chat.agent e2e harness to cover the run-lifecycle scenarios that previously needed the run-engine and a supervisor: idle-timeout suspend and resume, chat.endRun() continuation, and chat.requestUpgrade(). session.in.wait() suspends a run on a waitpoint whose only task-visible effect is that runtime.waitUntil() eventually resolves with the next .in record. A TestRuntimeManager plus a SessionWaitpointBackend reproduce that in one process: the two waitpoint apiClient calls are stubbed, and the backend opens its own tail on the session channel and resolves the wait with the next record, so the same run() invocation continues in place (matching local-dev warm resume and the task-observable semantics of a deployed CRIU restore). endRun/requestUpgrade exit the run; a small session orchestrator then spawns the next run as a continuation, mirroring the server re-triggering on the next append. It deliberately does not model server-side waitpoint bookkeeping or process checkpoint/restore, none of which are visible to task code on the resume path.
1 parent 31a76a7 commit 7d68f71

7 files changed

Lines changed: 529 additions & 18 deletions

File tree

apps/webapp/test/helpers/agentHarness.ts

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { apiClientManager, resourceCatalog } from "@trigger.dev/core/v3";
22
import type { LocalsKey } from "@trigger.dev/core/v3";
33
import type { LanguageModel } from "ai";
4-
import { runInMockTaskContext, StandardSessionStreamManager } from "@trigger.dev/core/v3/test";
4+
import {
5+
installSessionWaitpointBackend,
6+
runInMockTaskContext,
7+
StandardSessionStreamManager,
8+
} from "@trigger.dev/core/v3/test";
59

610
export type RunRealChatAgentOptions = {
711
agentId: string;
@@ -22,6 +26,12 @@ export type RunRealChatAgentOptions = {
2226
*/
2327
continuation?: boolean;
2428
previousRunId?: string;
29+
/**
30+
* Idle window (seconds) before the turn loop falls through from the SSE
31+
* once() to the suspending `session.in.wait()`. Set this low to force the
32+
* suspend/resume path in a test.
33+
*/
34+
idleTimeoutInSeconds?: number;
2535
};
2636

2737
export type RunningAgent = {
@@ -45,9 +55,11 @@ export function runRealChatAgent(opts: RunRealChatAgentOptions): RunningAgent {
4555
});
4656
const apiClient = apiClientManager.clientOrThrow();
4757
const manager = new StandardSessionStreamManager(apiClient, opts.baseUrl);
58+
const { runtimeManager, restore } = installSessionWaitpointBackend(apiClient);
4859

4960
const taskEntry = resourceCatalog.getTask(opts.agentId);
5061
if (!taskEntry) {
62+
restore();
5163
throw new Error(`runRealChatAgent: agent "${opts.agentId}" is not registered`);
5264
}
5365
const runFn = taskEntry.fns.run as (
@@ -58,21 +70,29 @@ export function runRealChatAgent(opts: RunRealChatAgentOptions): RunningAgent {
5870
const runSignal = new AbortController();
5971
const runId = opts.runId ?? `run_${opts.addressingKey}`;
6072

61-
const done = runInMockTaskContext(
62-
async (drivers) => {
63-
drivers.locals.set(opts.modelLocal, opts.model);
64-
const payload = opts.continuation
65-
? {
66-
chatId: opts.addressingKey,
67-
continuation: true,
68-
metadata: {},
69-
...(opts.previousRunId ? { previousRunId: opts.previousRunId } : {}),
70-
}
71-
: { chatId: opts.addressingKey, trigger: "preload", metadata: {} };
72-
await runFn(payload, { ctx: drivers.ctx, signal: runSignal.signal });
73-
},
74-
{ ctx: { run: { id: runId } }, sessionStreamManager: manager }
75-
) as Promise<void>;
73+
const idle =
74+
opts.idleTimeoutInSeconds !== undefined
75+
? { idleTimeoutInSeconds: opts.idleTimeoutInSeconds }
76+
: {};
77+
78+
const done = (
79+
runInMockTaskContext(
80+
async (drivers) => {
81+
drivers.locals.set(opts.modelLocal, opts.model);
82+
const payload = opts.continuation
83+
? {
84+
chatId: opts.addressingKey,
85+
continuation: true,
86+
metadata: {},
87+
...idle,
88+
...(opts.previousRunId ? { previousRunId: opts.previousRunId } : {}),
89+
}
90+
: { chatId: opts.addressingKey, trigger: "preload", metadata: {}, ...idle };
91+
await runFn(payload, { ctx: drivers.ctx, signal: runSignal.signal });
92+
},
93+
{ ctx: { run: { id: runId } }, sessionStreamManager: manager, runtimeManager }
94+
) as Promise<void>
95+
).finally(restore);
7696

7797
return {
7898
done,
@@ -102,3 +122,61 @@ export function runRealChatAgent(opts: RunRealChatAgentOptions): RunningAgent {
102122
},
103123
};
104124
}
125+
126+
export type ChatAgentSessionOptions = Omit<
127+
RunRealChatAgentOptions,
128+
"runId" | "continuation" | "previousRunId"
129+
>;
130+
131+
export type ChatAgentSession = {
132+
/** How many runs the session has spawned so far (1 fresh + N continuations). */
133+
runCount: () => number;
134+
/** Close the currently-active run and stop spawning continuations. */
135+
close: () => Promise<void>;
136+
};
137+
138+
/**
139+
* A session-scoped orchestrator that stands in for the run-engine's run
140+
* lifecycle: it starts a run, and whenever that run exits on its own
141+
* (`chat.endRun()` / `chat.requestUpgrade()`), spawns the next run as a
142+
* continuation (new run id, `continuation: true`, `previousRunId` threaded)
143+
* for the same session. That mirrors the server triggering a fresh run on the
144+
* next append after the previous run went terminal, and lets each continuation
145+
* restore prior history from the persisted snapshot. Runs never overlap: the
146+
* next spawn is chained on the previous run's `done` (after its manager
147+
* teardown), so the process-global managers are never installed twice at once.
148+
*/
149+
export function runChatAgentSession(opts: ChatAgentSessionOptions): ChatAgentSession {
150+
let closed = false;
151+
let index = 0;
152+
let current: RunningAgent | undefined;
153+
let previousRunId: string | undefined;
154+
155+
const spawn = () => {
156+
index += 1;
157+
const runId = `run_${opts.addressingKey}_${index}`;
158+
current = runRealChatAgent({
159+
...opts,
160+
runId,
161+
continuation: index > 1,
162+
previousRunId,
163+
});
164+
previousRunId = runId;
165+
const settle = () => {
166+
if (!closed) {
167+
spawn();
168+
}
169+
};
170+
current.done.then(settle, settle);
171+
};
172+
173+
spawn();
174+
175+
return {
176+
runCount: () => index,
177+
close: async () => {
178+
closed = true;
179+
await current?.close();
180+
},
181+
};
182+
}

apps/webapp/test/helpers/sessionStream.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ export function isTurnComplete(part: CollectedPart): boolean {
9292
return (part.headers ?? []).some(([k, v]) => k === "trigger-control" && v === "turn-complete");
9393
}
9494

95+
export function isUpgradeRequired(part: CollectedPart): boolean {
96+
return (part.headers ?? []).some(([k, v]) => k === "trigger-control" && v === "upgrade-required");
97+
}
98+
9599
export type SubscribeOptions = {
96100
baseUrl: string;
97101
addressingKey: string;

apps/webapp/test/helpers/testChatAgent.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,65 @@ export const testPlainChatAgent = chat.agent({
7878
},
7979
});
8080

81+
/**
82+
* A plain agent with a 1-second idle window, so the turn loop falls through to
83+
* the suspending `session.in.wait()` almost immediately instead of catching the
84+
* next message in the warm once() window. Used to exercise the suspend/resume
85+
* waitpoint path.
86+
*/
87+
export const testIdleChatAgent = chat.agent({
88+
id: "e2e-test-chat-idle",
89+
idleTimeoutInSeconds: 1,
90+
preloadIdleTimeoutInSeconds: 1,
91+
run: async ({ messages, signal }) => {
92+
const model = locals.get(testChatModelLocal);
93+
if (!model) {
94+
throw new Error("test model not injected via locals");
95+
}
96+
return streamText({ model, messages, abortSignal: signal });
97+
},
98+
});
99+
100+
/**
101+
* Ends the run after every turn via `chat.endRun()`. The next message on the
102+
* same chat starts a fresh continuation run (the orchestrator drives that).
103+
*/
104+
export const testEndRunChatAgent = chat.agent({
105+
id: "e2e-test-chat-endrun",
106+
idleTimeoutInSeconds: 2,
107+
preloadIdleTimeoutInSeconds: 2,
108+
run: async ({ messages, signal }) => {
109+
const model = locals.get(testChatModelLocal);
110+
if (!model) {
111+
throw new Error("test model not injected via locals");
112+
}
113+
const result = streamText({ model, messages, abortSignal: signal });
114+
chat.endRun();
115+
return result;
116+
},
117+
});
118+
119+
/**
120+
* Requests an upgrade from `onTurnStart` (the pre-turn path): `run()` is
121+
* skipped, an `upgrade-required` control record lands on `.out`, and the run
122+
* exits so a fresh run on the new version handles the message.
123+
*/
124+
export const testUpgradeChatAgent = chat.agent({
125+
id: "e2e-test-chat-upgrade",
126+
idleTimeoutInSeconds: 2,
127+
preloadIdleTimeoutInSeconds: 2,
128+
onTurnStart: async () => {
129+
chat.requestUpgrade();
130+
},
131+
run: async ({ messages, signal }) => {
132+
const model = locals.get(testChatModelLocal);
133+
if (!model) {
134+
throw new Error("test model not injected via locals");
135+
}
136+
return streamText({ model, messages, abortSignal: signal });
137+
},
138+
});
139+
81140
/**
82141
* A tool with a server-side `execute`: the agent runs it automatically and
83142
* feeds the result back to the model, so a single turn covers the whole

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

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,32 @@ import {
2222
appendInput,
2323
collectSessionOut,
2424
isTurnComplete,
25+
isUpgradeRequired,
2526
mintSessionToken,
2627
subscribeSessionOut,
2728
type CollectedPart,
2829
} from "./helpers/sessionStream";
29-
import { runRealChatAgent } from "./helpers/agentHarness";
30+
import { runChatAgentSession, runRealChatAgent } from "./helpers/agentHarness";
3031
import {
3132
testApprovalChatAgent,
3233
testChatAgent,
3334
testChatModelLocal,
35+
testEndRunChatAgent,
3436
testHitlChatAgent,
37+
testIdleChatAgent,
3538
testPlainChatAgent,
3639
testToolChatAgent,
40+
testUpgradeChatAgent,
3741
} from "./helpers/testChatAgent";
3842

43+
async function waitFor(predicate: () => boolean, maxMs: number): Promise<void> {
44+
const deadline = performance.now() + maxMs;
45+
while (performance.now() < deadline) {
46+
if (predicate()) return;
47+
await new Promise((r) => setTimeout(r, 100));
48+
}
49+
}
50+
3951
vi.setConfig({ testTimeout: 120_000, hookTimeout: 240_000 });
4052

4153
let server: SessionStreamTestServer;
@@ -965,4 +977,143 @@ describe("session agent e2e (real chat.agent loop)", () => {
965977
expect(outB).toContain("answer-for-B");
966978
expect(outB, "B's stream never sees A's output").not.toContain("answer-for-A");
967979
});
980+
981+
it("EA13: the run suspends on the idle waitpoint, then the next message resumes it in place", async () => {
982+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(testIdleChatAgent.id);
983+
const agent = runRealChatAgent({
984+
agentId: testIdleChatAgent.id,
985+
baseUrl,
986+
addressingKey,
987+
secretKey: apiKey,
988+
model: textModel("resumed after suspend"),
989+
modelLocal: testChatModelLocal,
990+
});
991+
992+
try {
993+
await new Promise((r) => setTimeout(r, 2500));
994+
await appendInput({
995+
baseUrl,
996+
addressingKey,
997+
token,
998+
partId: "u1",
999+
body: submitBody(addressingKey, userMessage("hi after idle", "u1")),
1000+
});
1001+
1002+
const { parts } = await collectSessionOut({
1003+
baseUrl,
1004+
addressingKey,
1005+
token,
1006+
until: (p) => p.some(isTurnComplete),
1007+
maxMs: 30_000,
1008+
});
1009+
1010+
expect(
1011+
joinChunks(parts),
1012+
"a message sent well after the idle window still produces a turn, so the waitpoint resume delivered it"
1013+
).toContain("resumed after suspend");
1014+
expect(parts.some(isTurnComplete)).toBe(true);
1015+
} finally {
1016+
await agent.close();
1017+
}
1018+
});
1019+
1020+
it("EA14: chat.endRun() ends the run; the next message continues on a fresh run", async () => {
1021+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(testEndRunChatAgent.id);
1022+
const session = runChatAgentSession({
1023+
agentId: testEndRunChatAgent.id,
1024+
baseUrl,
1025+
addressingKey,
1026+
secretKey: apiKey,
1027+
model: echoModel(),
1028+
modelLocal: testChatModelLocal,
1029+
});
1030+
1031+
try {
1032+
await appendInput({
1033+
baseUrl,
1034+
addressingKey,
1035+
token,
1036+
partId: "u1",
1037+
body: submitBody(addressingKey, userMessage("MARKER-ONE", "u1")),
1038+
});
1039+
await collectSessionOut({
1040+
baseUrl,
1041+
addressingKey,
1042+
token,
1043+
until: (p) => p.some(isTurnComplete),
1044+
maxMs: 30_000,
1045+
});
1046+
1047+
await waitFor(() => session.runCount() >= 2, 10_000);
1048+
expect(
1049+
session.runCount(),
1050+
"endRun exited run 1, so the orchestrator spawned a continuation"
1051+
).toBeGreaterThanOrEqual(2);
1052+
1053+
await appendInput({
1054+
baseUrl,
1055+
addressingKey,
1056+
token,
1057+
partId: "u2",
1058+
body: submitBody(addressingKey, userMessage("second msg", "u2")),
1059+
});
1060+
const { parts } = await collectSessionOut({
1061+
baseUrl,
1062+
addressingKey,
1063+
token,
1064+
until: (p) => p.filter(isTurnComplete).length >= 2,
1065+
maxMs: 30_000,
1066+
});
1067+
1068+
const blob = joinChunks(parts);
1069+
expect(blob, "the continuation run restored run 1's history").toContain("MARKER-ONE");
1070+
expect(blob, "the continuation run ran the new turn").toContain("second msg");
1071+
} finally {
1072+
await session.close();
1073+
}
1074+
});
1075+
1076+
it("EA15: chat.requestUpgrade() emits upgrade-required on .out and exits the run", async () => {
1077+
const { addressingKey, token, apiKey, baseUrl } = await setupSession(testUpgradeChatAgent.id);
1078+
const agent = runRealChatAgent({
1079+
agentId: testUpgradeChatAgent.id,
1080+
baseUrl,
1081+
addressingKey,
1082+
secretKey: apiKey,
1083+
model: echoModel(),
1084+
modelLocal: testChatModelLocal,
1085+
});
1086+
1087+
let exitedOnItsOwn = false;
1088+
agent.done.then(() => {
1089+
exitedOnItsOwn = true;
1090+
});
1091+
1092+
try {
1093+
await appendInput({
1094+
baseUrl,
1095+
addressingKey,
1096+
token,
1097+
partId: "u1",
1098+
body: submitBody(addressingKey, userMessage("upgrade me", "u1")),
1099+
});
1100+
const { parts } = await collectSessionOut({
1101+
baseUrl,
1102+
addressingKey,
1103+
token,
1104+
until: (p) => p.some(isUpgradeRequired),
1105+
maxMs: 30_000,
1106+
});
1107+
1108+
expect(
1109+
parts.some(isUpgradeRequired),
1110+
"an upgrade-required control record is written to .out"
1111+
).toBe(true);
1112+
1113+
await waitFor(() => exitedOnItsOwn, 10_000);
1114+
expect(exitedOnItsOwn, "the run exits itself after requesting the upgrade").toBe(true);
1115+
} finally {
1116+
await agent.close();
1117+
}
1118+
});
9681119
});

0 commit comments

Comments
 (0)