Skip to content

Commit 752a5aa

Browse files
committed
feat(chat): add chat.endRun()
Exit the loop after the current turn completes without the upgrade-required signal that chat.requestUpgrade() sends. Use when an agent finishes its work on its own terms — one-shot responses, goal achieved, budget exhausted — instead of waiting idle for the next user message. Callable from run(), chat.defer(), onBeforeTurnComplete, or onTurnComplete. Resolves TRI-8391.
1 parent cf05e0d commit 752a5aa

File tree

3 files changed

+91
-5
lines changed

3 files changed

+91
-5
lines changed

.changeset/chat-agent-end-run.md

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.endRun()` — exits the run after the current turn completes, without the upgrade-required signal that `chat.requestUpgrade()` sends. Use when an agent finishes its work on its own terms (one-shot responses, goal achieved, budget exhausted) instead of waiting idle for the next user message. Call from `run()`, `chat.defer()`, `onBeforeTurnComplete`, or `onTurnComplete`.

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,13 @@ const chatPrepareMessagesKey =
872872
/** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
873873
const chatUpgradeRequestedKey = locals.create<boolean>("chat.upgradeRequested");
874874

875+
/**
876+
* @internal Flag set by `chat.endRun()` to exit the loop after the current
877+
* turn completes, without any upgrade semantics. Checked at the same
878+
* post-turn / pre-wait sites as `chatUpgradeRequestedKey`.
879+
*/
880+
const chatEndRunRequestedKey = locals.create<boolean>("chat.endRunRequested");
881+
875882
/**
876883
* Event passed to `summarize` callbacks.
877884
*/
@@ -4160,7 +4167,11 @@ function chatAgent<
41604167

41614168
// chat.requestUpgrade() was called — exit the loop so the
41624169
// transport triggers a new run on the latest version.
4163-
if (locals.get(chatUpgradeRequestedKey)) {
4170+
// chat.endRun() — same exit, no upgrade semantics.
4171+
if (
4172+
locals.get(chatUpgradeRequestedKey) ||
4173+
locals.get(chatEndRunRequestedKey)
4174+
) {
41644175
return "exit";
41654176
}
41664177

@@ -4277,8 +4288,11 @@ function chatAgent<
42774288
// Best-effort — if stream write fails, let the run continue anyway
42784289
}
42794290

4280-
// chat.requestUpgrade() — exit after error turn too
4281-
if (locals.get(chatUpgradeRequestedKey)) {
4291+
// chat.requestUpgrade() / chat.endRun() — exit after error turn too
4292+
if (
4293+
locals.get(chatUpgradeRequestedKey) ||
4294+
locals.get(chatEndRunRequestedKey)
4295+
) {
42824296
return;
42834297
}
42844298

@@ -4861,6 +4875,36 @@ function requestUpgrade(): void {
48614875
locals.set(chatUpgradeRequestedKey, true);
48624876
}
48634877

4878+
/**
4879+
* Exit the run after the current turn completes, without waiting for the
4880+
* next message. Unlike {@link requestUpgrade}, no upgrade-required signal
4881+
* is sent to the client — the turn finishes normally, `onTurnComplete`
4882+
* fires, and the loop exits instead of going idle.
4883+
*
4884+
* Call from `run()`, `chat.defer()`, `onBeforeTurnComplete`, or
4885+
* `onTurnComplete` to end the run on your own terms (budget exhausted,
4886+
* task complete, goal achieved, etc.).
4887+
*
4888+
* The next user message on the same `chatId` starts a fresh run via the
4889+
* normal continuation mechanism.
4890+
*
4891+
* @example
4892+
* ```ts
4893+
* chat.agent({
4894+
* id: "one-shot-agent",
4895+
* run: async ({ messages, signal }) => {
4896+
* const result = streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
4897+
* // Single-response agent — exit after this turn.
4898+
* chat.endRun();
4899+
* return result;
4900+
* },
4901+
* });
4902+
* ```
4903+
*/
4904+
function endRun(): void {
4905+
locals.set(chatEndRunRequestedKey, true);
4906+
}
4907+
48644908
// ---------------------------------------------------------------------------
48654909
// Per-turn deferred work
48664910
// ---------------------------------------------------------------------------
@@ -5474,8 +5518,11 @@ function createChatSession(
54745518

54755519
// Subsequent turns: wait for the next message
54765520
if (turn > 0) {
5477-
// chat.requestUpgrade() — exit before waiting
5478-
if (locals.get(chatUpgradeRequestedKey)) {
5521+
// chat.requestUpgrade() / chat.endRun() — exit before waiting
5522+
if (
5523+
locals.get(chatUpgradeRequestedKey) ||
5524+
locals.get(chatEndRunRequestedKey)
5525+
) {
54795526
stop.cleanup();
54805527
return { done: true, value: undefined };
54815528
}
@@ -6108,6 +6155,8 @@ export const chat = {
61086155
isStopped,
61096156
/** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
61106157
requestUpgrade,
6158+
/** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
6159+
endRun,
61116160
/** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
61126161
cleanupAbortedParts,
61136162
/** Register background work that runs in parallel with streaming. See {@link chatDefer}. */

packages/trigger-sdk/test/mockChatAgent.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,38 @@ describe("mockChatAgent", () => {
236236
}
237237
});
238238

239+
it("chat.endRun() exits the loop after the current turn", async () => {
240+
const model = new MockLanguageModelV3({
241+
doStream: async () => ({ stream: textStream("bye") }),
242+
});
243+
244+
let turnCount = 0;
245+
const agent = chat.agent({
246+
id: "mockChatAgent.end-run",
247+
run: async ({ messages, signal }) => {
248+
turnCount++;
249+
chat.endRun();
250+
return streamText({ model, messages, abortSignal: signal });
251+
},
252+
});
253+
254+
const harness = mockChatAgent(agent, { chatId: "test-end-run" });
255+
try {
256+
await harness.sendMessage(userMessage("hello"));
257+
// Give the loop a tick to exit after the turn-complete chunk
258+
await new Promise((r) => setTimeout(r, 50));
259+
expect(turnCount).toBe(1);
260+
// Subsequent sends after endRun should not produce another run — the
261+
// loop has exited. We can't easily assert this via sendMessage (it
262+
// would block waiting for turn-complete), but we can verify the task
263+
// has finished.
264+
} finally {
265+
// close() is a no-op here since the task already exited, but call
266+
// for symmetry with other tests.
267+
await harness.close();
268+
}
269+
});
270+
239271
it("exposes finishReason on the onTurnComplete event", async () => {
240272
const model = new MockLanguageModelV3({
241273
doStream: async () => ({ stream: textStream("hi") }),

0 commit comments

Comments
 (0)