feat(chat): expose finishReason on turn-complete events

Surface the AI SDK's FinishReason on TurnCompleteEvent and
BeforeTurnCompleteEvent. Gives hooks a clean signal for distinguishing
a normal turn end from one paused on a pending tool call (HITL flows
like ask_user). Undefined for manual pipeChat() or aborted streams.
This commit is contained in:
Eric Allam
2026-04-18 15:53:20 +01:00
parent 220eeeeafd
commit fded2cd87e
3 changed files with 58 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Expose `finishReason` on `TurnCompleteEvent` and `BeforeTurnCompleteEvent`. Surfaces the AI SDK's `FinishReason` (`"stop" | "tool-calls" | "length" | ...`) so hooks can distinguish a normal turn end from one paused on a pending tool call (HITL flows like `ask_user`). Undefined for manual `pipeChat()` or aborted streams.
+27 -1
View File
@@ -17,6 +17,7 @@ import {
type TaskWithSchema,
} from "@trigger.dev/core/v3";
import type {
FinishReason,
ModelMessage,
ToolSet,
UIMessage,
@@ -2211,6 +2212,22 @@ export type TurnCompleteEvent<TClientData = unknown, TUIM extends UIMessage = UI
usage?: LanguageModelUsage;
/** Cumulative token usage across all turns in this run (including this turn). */
totalUsage: LanguageModelUsage;
/**
* Why the LLM stopped generating this turn:
* - `"stop"` — model generated a stop sequence (normal completion)
* - `"tool-calls"` — model stopped on one or more tool calls. If any tool
* has no `execute` function (e.g. an `ask_user` HITL tool), the turn is
* paused awaiting user input; inspect `responseMessage.parts` for tool
* parts in `input-available` state to distinguish.
* - `"length"` — max tokens reached
* - `"content-filter"` — content filter stopped the model
* - `"error"` — model errored
* - `"other"` — provider-specific reason
*
* Undefined if the underlying stream didn't provide a finish reason (e.g.
* manual `pipeChat()` or an aborted stream).
*/
finishReason?: FinishReason;
};
/**
@@ -3582,6 +3599,7 @@ function chatAgent<
// Captured by the onFinish callback below — works even on abort/stop.
let capturedResponseMessage: TUIMessage | undefined;
let capturedFinishReason: FinishReason | undefined;
// Promise that resolves when the AI SDK's onFinish fires.
// On abort, the stream's cancel() handler calls onFinish
@@ -3647,8 +3665,15 @@ function chatAgent<
// messageId. Without this, the frontend and backend generate IDs
// independently and they won't match for ID-based dedup.
generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId,
onFinish: ({ responseMessage }: { responseMessage: UIMessage }) => {
onFinish: ({
responseMessage,
finishReason,
}: {
responseMessage: UIMessage;
finishReason?: FinishReason;
}) => {
capturedResponseMessage = responseMessage as TUIMessage;
capturedFinishReason = finishReason;
resolveOnFinish!();
},
});
@@ -4012,6 +4037,7 @@ function chatAgent<
preloaded,
usage: turnUsage,
totalUsage: cumulativeUsage,
finishReason: capturedFinishReason,
};
// Fire onBeforeTurnComplete — stream is still open so the hook
@@ -236,6 +236,32 @@ describe("mockChatAgent", () => {
}
});
it("exposes finishReason on the onTurnComplete event", async () => {
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("hi") }),
});
let seenReason: string | undefined;
const agent = chat.agent({
id: "mockChatAgent.finish-reason",
onTurnComplete: async ({ finishReason }) => {
seenReason = finishReason;
},
run: async ({ messages, signal }) => {
return streamText({ model, messages, abortSignal: signal });
},
});
const harness = mockChatAgent(agent, { chatId: "test-finish-reason" });
try {
await harness.sendMessage(userMessage("hello"));
await new Promise((r) => setTimeout(r, 20));
expect(seenReason).toBe("stop");
} finally {
await harness.close();
}
});
it("seeds locals before run() via setupLocals (DI pattern)", async () => {
type FakeDb = { findUser(id: string): Promise<{ id: string; name: string }> };
const dbKey = locals.create<FakeDb>("test-db");