Skip to content

Commit c9e62ae

Browse files
committed
test(webapp): full-stack chat.agent e2e with the real turn loop
Runs the genuine chat.agent run loop in-process against the testcontainer stack (webapp + Postgres + Redis + s2-lite + MinIO): the agent's `.in`/`.out` go through the real webapp Session streams and its snapshots through the real object store, with the model injected as a deterministic MockLanguageModelV3. Turns are driven by appending to `.in` over HTTP and read back through the SSE proxy, so no real LLM or dev worker is involved. Covers a basic turn, multi-turn continuation on one run, and hydrateMessages. Adds two small test-only utilities to make this possible: a `StandardSessionStreamManager` export and a `sessionStreamManager` option on `runInMockTaskContext`, both from `@trigger.dev/core/v3/test`.
1 parent 8a5faec commit c9e62ae

6 files changed

Lines changed: 454 additions & 7 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { apiClientManager, resourceCatalog } from "@trigger.dev/core/v3";
2+
import type { LocalsKey } from "@trigger.dev/core/v3";
3+
import type { LanguageModel } from "ai";
4+
import { runInMockTaskContext, StandardSessionStreamManager } from "@trigger.dev/core/v3/test";
5+
6+
export type RunRealChatAgentOptions = {
7+
agentId: string;
8+
baseUrl: string;
9+
addressingKey: string;
10+
/**
11+
* The environment secret key. The agent writes `.out` and reads `.in` as the
12+
* backend (PRIVATE auth) — the `.out` channel rejects client session tokens.
13+
*/
14+
secretKey: string;
15+
model: LanguageModel;
16+
modelLocal: LocalsKey<LanguageModel>;
17+
runId?: string;
18+
};
19+
20+
export type RunningAgent = {
21+
done: Promise<void>;
22+
close: () => Promise<void>;
23+
};
24+
25+
/**
26+
* Run the real `chat.agent` turn loop in-process, wired to a running webapp:
27+
* `apiClientManager` + a real `StandardSessionStreamManager` point the agent's
28+
* `.in`/`.out` at the webapp's Session streams (real S2 + SSE), the model is
29+
* injected via locals (so it survives without serialization), and turns are
30+
* driven by appending to `.in` over HTTP. Callers keep each message inside the
31+
* idle window and `close()` promptly so the run-engine suspend path is never
32+
* reached.
33+
*/
34+
export function runRealChatAgent(opts: RunRealChatAgentOptions): RunningAgent {
35+
apiClientManager.setGlobalAPIClientConfiguration({
36+
baseURL: opts.baseUrl,
37+
accessToken: opts.secretKey,
38+
});
39+
const apiClient = apiClientManager.clientOrThrow();
40+
const manager = new StandardSessionStreamManager(apiClient, opts.baseUrl);
41+
42+
const taskEntry = resourceCatalog.getTask(opts.agentId);
43+
if (!taskEntry) {
44+
throw new Error(`runRealChatAgent: agent "${opts.agentId}" is not registered`);
45+
}
46+
const runFn = taskEntry.fns.run as (
47+
payload: unknown,
48+
params: { ctx: unknown; signal: AbortSignal }
49+
) => Promise<unknown>;
50+
51+
const runSignal = new AbortController();
52+
const runId = opts.runId ?? `run_${opts.addressingKey}`;
53+
54+
const done = runInMockTaskContext(
55+
async (drivers) => {
56+
drivers.locals.set(opts.modelLocal, opts.model);
57+
await runFn(
58+
{ chatId: opts.addressingKey, trigger: "preload", metadata: {} },
59+
{ ctx: drivers.ctx, signal: runSignal.signal }
60+
);
61+
},
62+
{ ctx: { run: { id: runId } }, sessionStreamManager: manager }
63+
) as Promise<void>;
64+
65+
return {
66+
done,
67+
close: async () => {
68+
try {
69+
await fetch(
70+
`${opts.baseUrl}/realtime/v1/sessions/${encodeURIComponent(opts.addressingKey)}/in/append`,
71+
{
72+
method: "POST",
73+
headers: {
74+
Authorization: `Bearer ${opts.secretKey}`,
75+
"Content-Type": "application/json",
76+
"X-Part-Id": "close",
77+
},
78+
body: JSON.stringify({
79+
kind: "message",
80+
payload: { chatId: opts.addressingKey, trigger: "close" },
81+
}),
82+
}
83+
);
84+
} catch {}
85+
runSignal.abort();
86+
await Promise.race([
87+
done.catch(() => {}),
88+
new Promise((resolve) => setTimeout(resolve, 10_000)),
89+
]);
90+
},
91+
};
92+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { chat } from "@trigger.dev/sdk/ai";
2+
import { locals } from "@trigger.dev/core/v3";
3+
import { streamText, type LanguageModel, type UIMessage } from "ai";
4+
import { z } from "zod";
5+
6+
/**
7+
* The model is injected through locals (an in-process, by-reference value)
8+
* rather than clientData, because a `MockLanguageModelV3` can't survive the
9+
* JSON round-trip through the real `.in/append` route. The harness sets this
10+
* before the run starts.
11+
*/
12+
export const testChatModelLocal = locals.create<LanguageModel>("e2e-test-chat.model");
13+
14+
export type TestChatClientData = { hydrated?: UIMessage[] };
15+
16+
function firstText(m: UIMessage): string {
17+
const p = m.parts?.[0];
18+
return p?.type === "text" ? p.text : "";
19+
}
20+
21+
export const testChatAgent = chat
22+
.withClientData({
23+
schema: z.custom<TestChatClientData>((v) => v == null || typeof v === "object"),
24+
})
25+
.agent({
26+
id: "e2e-test-chat",
27+
28+
onValidateMessages: async ({ messages }) => {
29+
for (const m of messages) {
30+
if (m.role === "user" && firstText(m).toLowerCase().includes("blocked-word")) {
31+
throw new Error("Message blocked by content filter");
32+
}
33+
}
34+
return messages;
35+
},
36+
37+
hydrateMessages: async ({ clientData, incomingMessages }) => {
38+
if (!clientData?.hydrated) return incomingMessages;
39+
const merged = [...clientData.hydrated];
40+
for (const m of incomingMessages) {
41+
const idx = merged.findIndex((x) => x.id === m.id);
42+
if (idx === -1) merged.push(m);
43+
else merged[idx] = m;
44+
}
45+
return merged;
46+
},
47+
48+
actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("undo") })]),
49+
50+
onAction: async ({ action }) => {
51+
if (action.type === "undo") {
52+
chat.history.slice(0, -2);
53+
}
54+
},
55+
56+
run: async ({ messages, signal }) => {
57+
const model = locals.get(testChatModelLocal);
58+
if (!model) {
59+
throw new Error("test model not injected via locals");
60+
}
61+
return streamText({ model, messages, abortSignal: signal });
62+
},
63+
});

0 commit comments

Comments
 (0)