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");