Skip to content

Commit 073dbc2

Browse files
committed
fix(sdk): make newUIMessages on HITL match the non-hydrate branch
1 parent 41f138d commit 073dbc2

2 files changed

Lines changed: 15 additions & 37 deletions

File tree

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

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6216,23 +6216,24 @@ function chatAgent<
62166216
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
62176217

62186218
// Track new messages for onTurnComplete.newUIMessages.
6219-
// Surface the post-merge entry when the wire copy
6220-
// matched a hydrated message — the wire copy may have
6221-
// been slimmed (HITL tool-output continuation), and
6222-
// customers expect `newUIMessages` to carry full
6223-
// content (text, reasoning, tool `input`).
6219+
// Only push for genuinely new ids — HITL continuations
6220+
// whose incoming wire id matches an existing hydrated
6221+
// entry are state advances on an old message, not new
6222+
// messages. The non-hydrate branch below has the same
6223+
// semantic (push only on append, not on merge).
62246224
if (
62256225
currentWirePayload.trigger === "submit-message" &&
62266226
cleanedUIMessages.length > 0
62276227
) {
62286228
const lastUI = cleanedUIMessages[cleanedUIMessages.length - 1]!;
6229-
const mergedEntry = lastUI.id
6230-
? merged.find((m) => m.id === lastUI.id)
6231-
: undefined;
6232-
const surfaceUI = (mergedEntry ?? lastUI) as TUIMessage;
6233-
turnNewUIMessages.push(surfaceUI);
6234-
const lastModel = (await toModelMessages([surfaceUI]))[0];
6235-
if (lastModel) turnNewModelMessages.push(lastModel);
6229+
const matchedExisting =
6230+
lastUI.id !== undefined &&
6231+
hydrated.some((m) => m.id === lastUI.id);
6232+
if (!matchedExisting) {
6233+
turnNewUIMessages.push(lastUI);
6234+
const lastModel = (await toModelMessages([lastUI]))[0];
6235+
if (lastModel) turnNewModelMessages.push(lastModel);
6236+
}
62366237
}
62376238
} else {
62386239
// Default delta-merge accumulation.

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

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { mockChatAgent } from "../src/v3/test/index.js";
55
import { describe, expect, it, vi } from "vitest";
66
import { chat } from "../src/v3/ai.js";
77
import { locals } from "@trigger.dev/core/v3";
8-
import { simulateReadableStream, streamText } from "ai";
8+
import { simulateReadableStream, streamText, tool, validateUIMessages } from "ai";
99
import { MockLanguageModelV3 } from "ai/test";
1010
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
11+
import { z } from "zod";
1112

1213
// ── Helpers ────────────────────────────────────────────────────────────
1314

@@ -185,8 +186,6 @@ describe("mockChatAgent", () => {
185186
// an assistant with tool parts lands in the accumulator and uses that
186187
// map as a fallback in the merge so a fresh-id incoming still attaches
187188
// to the right head.
188-
const { z } = await import("zod");
189-
const { tool } = await import("ai");
190189

191190
const askUserTool = tool({
192191
description: "Ask the user a question.",
@@ -314,8 +313,6 @@ describe("mockChatAgent", () => {
314313
// the hydrated `input` and overlay only the wire's `state` +
315314
// `output` — otherwise the next LLM call ships a tool call with no
316315
// `arguments` and the provider 400s.
317-
const { z } = await import("zod");
318-
const { tool } = await import("ai");
319316
const searchTool = tool({
320317
description: "Search.",
321318
inputSchema: z.object({ query: z.string() }),
@@ -395,8 +392,6 @@ describe("mockChatAgent", () => {
395392
// approved }`. Hydrated has the same tool part in
396393
// `approval-requested`. Merge has to overlay state + approval onto
397394
// hydrated while keeping `input` intact so the agent can resume.
398-
const { z } = await import("zod");
399-
const { tool } = await import("ai");
400395
const deleteTool = tool({
401396
description: "Delete.",
402397
inputSchema: z.object({ resource: z.string() }),
@@ -475,8 +470,6 @@ describe("mockChatAgent", () => {
475470
// prior `approval-responded` (replay, retry, out-of-order arrival)
476471
// must NOT regress the terminal denial back to a pre-resolution
477472
// state — the agent would then re-run the tool.
478-
const { z } = await import("zod");
479-
const { tool } = await import("ai");
480473
const deleteTool = tool({
481474
description: "Delete.",
482475
inputSchema: z.object({ resource: z.string() }),
@@ -565,8 +558,6 @@ describe("mockChatAgent", () => {
565558
// This test pins both behaviors: (1) the slim assistant does arrive
566559
// in the validate hook on the HITL turn, (2) a fresh-user-only
567560
// filter still works (the user message turn is unaffected).
568-
const { z } = await import("zod");
569-
const { tool, validateUIMessages } = await import("ai");
570561
const askUser = tool({
571562
description: "Ask the user.",
572563
inputSchema: z.object({ q: z.string() }),
@@ -696,8 +687,6 @@ describe("mockChatAgent", () => {
696687
// The agent's own turn-1 output seeds the accumulator with the
697688
// full assistant + tool `input`; a slim turn-2 wire copy has to
698689
// merge onto that without clobbering the snapshot's `input`.
699-
const { z } = await import("zod");
700-
const { tool } = await import("ai");
701690
const askUser = tool({
702691
description: "Ask the user.",
703692
inputSchema: z.object({ q: z.string() }),
@@ -820,7 +809,6 @@ describe("mockChatAgent", () => {
820809

821810
const onActionSpy = vi.fn();
822811

823-
const { z } = await import("zod");
824812
const agent = chat.agent({
825813
id: "mockChatAgent.actions",
826814
actionSchema: z.object({
@@ -859,7 +847,6 @@ describe("mockChatAgent", () => {
859847
},
860848
});
861849

862-
const { z } = await import("zod");
863850
const agent = chat.agent({
864851
id: "mockChatAgent.actions.void",
865852
actionSchema: z.object({ type: z.literal("undo") }),
@@ -927,7 +914,6 @@ describe("mockChatAgent", () => {
927914
doStream: async () => ({ stream: textStream("normal-response") }),
928915
});
929916

930-
const { z } = await import("zod");
931917
const agent = chat.agent({
932918
id: "mockChatAgent.actions.stream",
933919
actionSchema: z.object({ type: z.literal("regenerate") }),
@@ -976,7 +962,6 @@ describe("mockChatAgent", () => {
976962
},
977963
});
978964

979-
const { z } = await import("zod");
980965
const agent = chat.agent({
981966
id: "mockChatAgent.actions.no-handler",
982967
actionSchema: z.object({ type: z.literal("undo") }),
@@ -1167,8 +1152,6 @@ describe("mockChatAgent", () => {
11671152
}
11681153

11691154
it("getPendingToolCalls returns input-available parts on the leaf assistant", async () => {
1170-
const { z } = await import("zod");
1171-
const { tool } = await import("ai");
11721155
const askUser = tool({
11731156
description: "Ask the user.",
11741157
inputSchema: z.object({ q: z.string() }),
@@ -1230,8 +1213,6 @@ describe("mockChatAgent", () => {
12301213
});
12311214

12321215
it("getResolvedToolCalls walks all messages after a HITL answer lands", async () => {
1233-
const { z } = await import("zod");
1234-
const { tool } = await import("ai");
12351216
const askUser = tool({
12361217
description: "Ask the user.",
12371218
inputSchema: z.object({ q: z.string() }),
@@ -1411,8 +1392,6 @@ describe("mockChatAgent", () => {
14111392
it("extractNewToolResults dedups against a real-stream-built chain", async () => {
14121393
// Build the chain through real model streams (no chat.history.set seed)
14131394
// and assert extractNewToolResults compares against the post-merge state.
1414-
const { z } = await import("zod");
1415-
const { tool } = await import("ai");
14161395
const askUser = tool({
14171396
description: "Ask the user.",
14181397
inputSchema: z.object({ q: z.string() }),
@@ -1497,8 +1476,6 @@ describe("mockChatAgent", () => {
14971476
// assistant via the toolCallId map. Here we send an answer in
14981477
// `output-error` state and verify (a) getResolvedToolCalls reports
14991478
// it, and (b) extractNewToolResults emits it with errorText set.
1500-
const { z } = await import("zod");
1501-
const { tool } = await import("ai");
15021479
const search = tool({
15031480
description: "Search.",
15041481
inputSchema: z.object({ q: z.string() }),

0 commit comments

Comments
 (0)