From fded2cd87e31606a8efdc2e6a626f59ff1a7b43d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 18 Apr 2026 15:53:20 +0100 Subject: [PATCH] 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. --- .changeset/chat-agent-finish-reason.md | 5 ++++ packages/trigger-sdk/src/v3/ai.ts | 28 ++++++++++++++++++- .../trigger-sdk/test/mockChatAgent.test.ts | 26 +++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .changeset/chat-agent-finish-reason.md diff --git a/.changeset/chat-agent-finish-reason.md b/.changeset/chat-agent-finish-reason.md new file mode 100644 index 000000000..bf6a3b238 --- /dev/null +++ b/.changeset/chat-agent-finish-reason.md @@ -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. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index be9237c8f..dd5b622e1 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -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 { + 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 diff --git a/packages/trigger-sdk/test/mockChatAgent.test.ts b/packages/trigger-sdk/test/mockChatAgent.test.ts index 8ebb2c812..15f42a0e3 100644 --- a/packages/trigger-sdk/test/mockChatAgent.test.ts +++ b/packages/trigger-sdk/test/mockChatAgent.test.ts @@ -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("test-db");