Skip to content

Commit 4e00651

Browse files
gtremperericallam
andauthored
feat(chat): expose endAndContinue to custom agents (#4647)
## Summary Raw `chat.customAgent()` loops can now call `chat.endAndContinue()` to move the Session to a fresh run. The managed loop already used the same server operation through `chat.requestUpgrade()`, but raw loops could not call it directly. Call the method between turns after detaching input listeners from the old run. Await it and return immediately. Unconsumed `.in` records stay on the Session for the continuation run. I put this on the `chat` namespace next to the other raw chat primitives. Happy to move it if maintainers prefer a different API placement. ## Testing - `pnpm exec vitest run` in `packages/trigger-sdk` (374 tests) - Focused webapp Session E2E tests (3 tests) - `pnpm run build` in `packages/trigger-sdk` - Webapp typecheck - `pnpm run format` - `pnpm run lint` ## Checklist - [x] I followed the contributing guide - [x] The PR title follows the convention - [x] I tested the change ## Changelog Allow custom chat agents to rotate to a new task version without dropping unconsumed Session input. --------- Co-authored-by: Eric Allam <eallam@icloud.com>
1 parent d54bcaa commit 4e00651

8 files changed

Lines changed: 586 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Add `chat.endAndContinue()` so fully hand-rolled custom chat agents can hand a conversation off to a fresh run on the latest deployed task version while preserving unconsumed Session input.

apps/webapp/test/helpers/testChatAgent.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,119 @@ export const testUpgradeOnceChatAgent = chat.agent({
234234
},
235235
});
236236

237+
/**
238+
* Hands an unconsumed Session input record to a continuation run using the
239+
* public custom-agent lifecycle primitive. The continuation echoes the input
240+
* to `.out`, which lets the full-stack Session E2E assert durable delivery.
241+
*/
242+
export const testEndAndContinueCustomAgent = chat.customAgent({
243+
id: "e2e-test-chat-custom-end-and-continue",
244+
run: async (payload) => {
245+
if (!payload.continuation) {
246+
await chat.endAndContinue();
247+
return;
248+
}
249+
250+
const next = await chat.messages.waitWithIdleTimeout({
251+
idleTimeoutInSeconds: 2,
252+
timeout: "1m",
253+
});
254+
if (!next.ok) {
255+
throw next.error;
256+
}
257+
258+
const message = next.output.message as UIMessage | undefined;
259+
const text = message ? firstText(message) : "";
260+
const { waitUntilComplete } = chat.stream.writer({
261+
execute: ({ write }) => {
262+
write({ type: "text-start", id: "handoff-result" });
263+
write({ type: "text-delta", id: "handoff-result", delta: `received:${text}` });
264+
write({ type: "text-end", id: "handoff-result" });
265+
},
266+
});
267+
await waitUntilComplete();
268+
await chat.writeTurnComplete();
269+
},
270+
});
271+
272+
export const endAndContinueGuardEvents: Array<{
273+
chatId: string;
274+
kind: "guard-held" | "return-settled";
275+
}> = [];
276+
277+
const activeSessionIteratorError =
278+
"chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().";
279+
280+
async function expectActiveSessionIteratorError() {
281+
try {
282+
await chat.endAndContinue();
283+
} catch (error) {
284+
if (error instanceof Error && error.message === activeSessionIteratorError) return;
285+
throw error;
286+
}
287+
throw new Error("Expected chat.endAndContinue() to reject while the iterator is active");
288+
}
289+
290+
/** Exercises a return racing an already-started next() against real Session input. */
291+
export const testEndAndContinueIteratorGuardCustomAgent = chat.customAgent({
292+
id: "e2e-test-chat-custom-end-and-continue-iterator-guard",
293+
run: async (payload, { signal }) => {
294+
if (payload.continuation) {
295+
const next = await chat.messages.waitWithIdleTimeout({
296+
idleTimeoutInSeconds: 2,
297+
timeout: "1m",
298+
});
299+
if (!next.ok) {
300+
throw next.error;
301+
}
302+
303+
const message = next.output.message as UIMessage | undefined;
304+
const text = message ? firstText(message) : "";
305+
const { waitUntilComplete } = chat.stream.writer({
306+
execute: ({ write }) => {
307+
write({ type: "text-start", id: "guard-continuation-result" });
308+
write({
309+
type: "text-delta",
310+
id: "guard-continuation-result",
311+
delta: `received:${text}`,
312+
});
313+
write({ type: "text-end", id: "guard-continuation-result" });
314+
},
315+
});
316+
await waitUntilComplete();
317+
await chat.writeTurnComplete();
318+
return;
319+
}
320+
321+
const iterator = chat.createSession(payload, { signal })[Symbol.asyncIterator]();
322+
const firstTurn = await iterator.next();
323+
if (firstTurn.done) {
324+
throw new Error("Expected an initial chat turn");
325+
}
326+
await firstTurn.value.done();
327+
await expectActiveSessionIteratorError();
328+
329+
const pendingNext = iterator.next();
330+
if (!iterator.return) {
331+
throw new Error("Expected the chat Session iterator to support return()");
332+
}
333+
const pendingReturn = iterator.return();
334+
335+
// Let an immediately-resolving return() clear a broken guard before checking it.
336+
await new Promise((resolve) => setTimeout(resolve, 0));
337+
await expectActiveSessionIteratorError();
338+
endAndContinueGuardEvents.push({ chatId: payload.chatId, kind: "guard-held" });
339+
340+
const [nextResult] = await Promise.all([pendingNext, pendingReturn]);
341+
if (!nextResult.done) {
342+
throw new Error("Expected return() to suppress the pending next() turn");
343+
}
344+
endAndContinueGuardEvents.push({ chatId: payload.chatId, kind: "return-settled" });
345+
346+
await chat.endAndContinue();
347+
},
348+
});
349+
237350
/**
238351
* A tool with a server-side `execute`: the agent runs it automatically and
239352
* feeds the result back to the model, so a single turn covers the whole

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

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@ import {
2929
} from "./helpers/sessionStream";
3030
import { runChatAgentSession, runRealChatAgent } from "./helpers/agentHarness";
3131
import {
32+
endAndContinueGuardEvents,
3233
suspendResumeEvents,
3334
testApprovalChatAgent,
3435
testChatAgent,
3536
testChatModelLocal,
37+
testEndAndContinueCustomAgent,
38+
testEndAndContinueIteratorGuardCustomAgent,
3639
testEndRunChatAgent,
3740
testHitlChatAgent,
3841
testHitlIdleChatAgent,
@@ -123,6 +126,34 @@ async function setupSession(agentId: string = testChatAgent.id) {
123126
return { addressingKey, token, apiKey, baseUrl: server.webapp.baseUrl };
124127
}
125128

129+
async function setupStartedSession(agentId: string) {
130+
const { environment, apiKey } = await seedTestEnvironment(server.prisma);
131+
const addressingKey = `chat-${randomBytes(6).toString("hex")}`;
132+
const createRes = await fetch(`${server.webapp.baseUrl}/api/v1/sessions`, {
133+
method: "POST",
134+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
135+
body: JSON.stringify({
136+
type: "chat.agent",
137+
externalId: addressingKey,
138+
taskIdentifier: agentId,
139+
triggerConfig: { basePayload: {} },
140+
}),
141+
});
142+
143+
expect(createRes.ok).toBe(true);
144+
const created = (await createRes.json()) as {
145+
runId: string;
146+
publicAccessToken: string;
147+
};
148+
return {
149+
...created,
150+
addressingKey,
151+
apiKey,
152+
environment,
153+
baseUrl: server.webapp.baseUrl,
154+
};
155+
}
156+
126157
function promptText(prompt: unknown): string {
127158
if (!Array.isArray(prompt)) return "";
128159
let out = "";
@@ -1533,4 +1564,211 @@ describe("session agent e2e (real chat.agent loop)", () => {
15331564
await agent.close();
15341565
}
15351566
});
1567+
1568+
it("EA23: custom endAndContinue hands pending input to a fresh run", async () => {
1569+
const { addressingKey, publicAccessToken, runId, apiKey, environment, baseUrl } =
1570+
await setupStartedSession(testEndAndContinueCustomAgent.id);
1571+
const initialRun = await server.prisma.taskRun.findFirstOrThrow({
1572+
where: { friendlyId: runId },
1573+
select: { id: true },
1574+
});
1575+
1576+
const append = await appendInput({
1577+
baseUrl,
1578+
addressingKey,
1579+
token: publicAccessToken,
1580+
partId: "pending-handoff-input",
1581+
body: submitBody(
1582+
addressingKey,
1583+
userMessage("deliver after endAndContinue", "pending-handoff-input")
1584+
),
1585+
});
1586+
expect(append.status).toBe(200);
1587+
1588+
const oldRun = runRealChatAgent({
1589+
agentId: testEndAndContinueCustomAgent.id,
1590+
baseUrl,
1591+
addressingKey,
1592+
secretKey: apiKey,
1593+
model: textModel("unused"),
1594+
modelLocal: testChatModelLocal,
1595+
runId,
1596+
});
1597+
let continuation: ReturnType<typeof runRealChatAgent> | undefined;
1598+
1599+
try {
1600+
await expect(oldRun.done).resolves.toBeUndefined();
1601+
1602+
const session = await server.prisma.session.findFirstOrThrow({
1603+
where: { runtimeEnvironmentId: environment.id, externalId: addressingKey },
1604+
select: { currentRunId: true, currentRunVersion: true },
1605+
});
1606+
expect(session.currentRunId).not.toBe(initialRun.id);
1607+
expect(session.currentRunVersion).toBeGreaterThan(1);
1608+
1609+
const successor = await server.prisma.taskRun.findFirstOrThrow({
1610+
where: { id: session.currentRunId! },
1611+
select: { friendlyId: true },
1612+
});
1613+
continuation = runRealChatAgent({
1614+
agentId: testEndAndContinueCustomAgent.id,
1615+
baseUrl,
1616+
addressingKey,
1617+
secretKey: apiKey,
1618+
model: textModel("unused"),
1619+
modelLocal: testChatModelLocal,
1620+
runId: successor.friendlyId,
1621+
continuation: true,
1622+
previousRunId: runId,
1623+
});
1624+
1625+
const { parts } = await collectSessionOut({
1626+
baseUrl,
1627+
addressingKey,
1628+
token: publicAccessToken,
1629+
until: (p) => p.some(isTurnComplete),
1630+
maxMs: 30_000,
1631+
});
1632+
expect(joinChunks(parts)).toContain("received:deliver after endAndContinue");
1633+
await expect(continuation.done).resolves.toBeUndefined();
1634+
} finally {
1635+
await continuation?.close();
1636+
await oldRun.close();
1637+
}
1638+
});
1639+
1640+
it("EA24: custom endAndContinue rejects when the server rejects the handoff", async () => {
1641+
const { addressingKey, apiKey, baseUrl } = await setupStartedSession(
1642+
testEndAndContinueCustomAgent.id
1643+
);
1644+
const agent = runRealChatAgent({
1645+
agentId: testEndAndContinueCustomAgent.id,
1646+
baseUrl,
1647+
addressingKey,
1648+
secretKey: apiKey,
1649+
model: textModel("unused"),
1650+
modelLocal: testChatModelLocal,
1651+
runId: "run_missing_end_and_continue",
1652+
});
1653+
1654+
try {
1655+
await expect(agent.done).rejects.toThrow("callingRunId not found in this environment");
1656+
} finally {
1657+
await agent.close();
1658+
}
1659+
});
1660+
1661+
it("EA25: custom endAndContinue keeps the guard while iterator next is active", async () => {
1662+
const { addressingKey, publicAccessToken, runId, apiKey, environment, baseUrl } =
1663+
await setupStartedSession(testEndAndContinueIteratorGuardCustomAgent.id);
1664+
const initialRun = await server.prisma.taskRun.findFirstOrThrow({
1665+
where: { friendlyId: runId },
1666+
select: { id: true },
1667+
});
1668+
1669+
const append = await appendInput({
1670+
baseUrl,
1671+
addressingKey,
1672+
token: publicAccessToken,
1673+
partId: "iterator-guard-initial-input",
1674+
body: submitBody(
1675+
addressingKey,
1676+
userMessage("start iterator guard test", "iterator-guard-initial-input")
1677+
),
1678+
});
1679+
expect(append.status).toBe(200);
1680+
1681+
const agent = runRealChatAgent({
1682+
agentId: testEndAndContinueIteratorGuardCustomAgent.id,
1683+
baseUrl,
1684+
addressingKey,
1685+
secretKey: apiKey,
1686+
model: textModel("unused"),
1687+
modelLocal: testChatModelLocal,
1688+
runId,
1689+
});
1690+
let agentSettled = false;
1691+
let agentFailure: unknown;
1692+
void agent.done.then(
1693+
() => {
1694+
agentSettled = true;
1695+
},
1696+
(error) => {
1697+
agentSettled = true;
1698+
agentFailure = error;
1699+
}
1700+
);
1701+
let continuation: ReturnType<typeof runRealChatAgent> | undefined;
1702+
1703+
try {
1704+
await waitFor(
1705+
() =>
1706+
agentSettled ||
1707+
endAndContinueGuardEvents.some(
1708+
(event) => event.chatId === addressingKey && event.kind === "guard-held"
1709+
),
1710+
20_000
1711+
);
1712+
if (agentFailure) throw agentFailure;
1713+
expect(agentSettled).toBe(false);
1714+
expect(
1715+
endAndContinueGuardEvents.some(
1716+
(event) => event.chatId === addressingKey && event.kind === "guard-held"
1717+
)
1718+
).toBe(true);
1719+
1720+
const release = await appendInput({
1721+
baseUrl,
1722+
addressingKey,
1723+
token: publicAccessToken,
1724+
partId: "iterator-guard-release-input",
1725+
body: submitBody(
1726+
addressingKey,
1727+
userMessage("release pending next", "iterator-guard-release-input")
1728+
),
1729+
});
1730+
expect(release.status).toBe(200);
1731+
await expect(agent.done).resolves.toBeUndefined();
1732+
1733+
expect(
1734+
endAndContinueGuardEvents.some(
1735+
(event) => event.chatId === addressingKey && event.kind === "return-settled"
1736+
)
1737+
).toBe(true);
1738+
const session = await server.prisma.session.findFirstOrThrow({
1739+
where: { runtimeEnvironmentId: environment.id, externalId: addressingKey },
1740+
select: { currentRunId: true },
1741+
});
1742+
expect(session.currentRunId).not.toBe(initialRun.id);
1743+
1744+
const successor = await server.prisma.taskRun.findFirstOrThrow({
1745+
where: { id: session.currentRunId! },
1746+
select: { friendlyId: true },
1747+
});
1748+
continuation = runRealChatAgent({
1749+
agentId: testEndAndContinueIteratorGuardCustomAgent.id,
1750+
baseUrl,
1751+
addressingKey,
1752+
secretKey: apiKey,
1753+
model: textModel("unused"),
1754+
modelLocal: testChatModelLocal,
1755+
runId: successor.friendlyId,
1756+
continuation: true,
1757+
previousRunId: runId,
1758+
});
1759+
1760+
const { parts } = await collectSessionOut({
1761+
baseUrl,
1762+
addressingKey,
1763+
token: publicAccessToken,
1764+
until: (records) => records.filter(isTurnComplete).length >= 2,
1765+
maxMs: 30_000,
1766+
});
1767+
expect(joinChunks(parts)).toContain("received:release pending next");
1768+
await expect(continuation.done).resolves.toBeUndefined();
1769+
} finally {
1770+
await continuation?.close();
1771+
await agent.close();
1772+
}
1773+
});
15361774
});

0 commit comments

Comments
 (0)