Skip to content

Commit b038a1f

Browse files
committed
feat(chat): tool approvals support — ID-matched message replacement, sendEmail example, approval UI
1 parent e083f75 commit b038a1f

File tree

4 files changed

+133
-14
lines changed

4 files changed

+133
-14
lines changed

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

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3025,11 +3025,32 @@ function chatAgent<
30253025
accumulatedUIMessages = [...cleanedUIMessages];
30263026
// No new user messages for regenerate — just the response (added below)
30273027
} else {
3028-
// Submit: frontend sent only the new user message(s). Append to accumulator.
3029-
accumulatedMessages.push(...incomingModelMessages);
3030-
accumulatedUIMessages.push(...cleanedUIMessages);
3031-
turnNewModelMessages.push(...incomingModelMessages);
3032-
turnNewUIMessages.push(...cleanedUIMessages);
3028+
// Submit: check if any incoming message updates an existing one (by ID).
3029+
// This handles tool approval responses, where the frontend resends the
3030+
// assistant message with updated tool parts (approval-responded).
3031+
// IDs match because we always pass generateMessageId + originalMessages
3032+
// to toUIMessageStream, so the backend's start chunk carries the same
3033+
// messageId that the frontend uses.
3034+
let replaced = false;
3035+
for (const incoming of cleanedUIMessages) {
3036+
const idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
3037+
if (idx !== -1) {
3038+
accumulatedUIMessages[idx] = incoming as TUIMessage;
3039+
replaced = true;
3040+
} else {
3041+
accumulatedUIMessages.push(incoming as TUIMessage);
3042+
turnNewUIMessages.push(incoming as TUIMessage);
3043+
}
3044+
}
3045+
if (replaced) {
3046+
// Reconvert all model messages since a replacement changes the structure
3047+
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
3048+
} else {
3049+
accumulatedMessages.push(...incomingModelMessages);
3050+
}
3051+
if (turnNewUIMessages.length > 0) {
3052+
turnNewModelMessages.push(...(await toModelMessages(turnNewUIMessages)));
3053+
}
30333054
}
30343055

30353056
// Mint a scoped public access token once per turn, reused for
@@ -3157,15 +3178,20 @@ function chatAgent<
31573178
let runResult: unknown;
31583179

31593180
try {
3160-
// Drain any messages injected by background work (e.g. self-review from previous turn)
3181+
// Drain any messages injected by background work (e.g. self-review from previous turn).
3182+
// Skip if the last message is a tool message — appending after it would
3183+
// prevent streamText from finding pending tool approvals (it checks
3184+
// the last message). The queued messages will be picked up by prepareStep
3185+
// at the next step boundary instead.
3186+
const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
31613187
const bgQueue = locals.get(chatBackgroundQueueKey);
3162-
if (bgQueue && bgQueue.length > 0) {
3188+
if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
31633189
accumulatedMessages.push(...bgQueue.splice(0));
31643190
}
31653191

31663192
runResult = await userRun({
31673193
...restWire,
3168-
messages: await applyPrepareMessages(accumulatedMessages, "run"),
3194+
messages: messagesForRun,
31693195
clientData,
31703196
continuation,
31713197
previousRunId,
@@ -3185,9 +3211,16 @@ function chatAgent<
31853211
// (e.g. for tool approval continuations / HITL flows).
31863212
if ((locals.get(chatPipeCountKey) ?? 0) === 0 && isUIMessageStreamable(runResult)) {
31873213
onFinishAttached = true;
3214+
const resolvedOptions = resolveUIMessageStreamOptions();
31883215
const uiStream = runResult.toUIMessageStream({
3189-
...resolveUIMessageStreamOptions(),
3216+
...resolvedOptions,
3217+
// Pass originalMessages so the AI SDK reuses message IDs across
3218+
// turns (e.g. for tool approval continuations / HITL flows).
31903219
originalMessages: accumulatedUIMessages,
3220+
// Always provide generateMessageId so the start chunk carries a
3221+
// messageId. Without this, the frontend and backend generate IDs
3222+
// independently and they won't match for ID-based dedup.
3223+
generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId,
31913224
onFinish: ({ responseMessage }: { responseMessage: UIMessage }) => {
31923225
capturedResponseMessage = responseMessage as TUIMessage;
31933226
resolveOnFinish!();

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,9 +463,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
463463
// If we have an existing run, send the message via input stream
464464
// to resume the conversation in the same run.
465465
if (session?.runId) {
466+
const slicedMessages = trigger === "submit-message" ? messages.slice(-1) : messages;
466467
const minimalPayload = {
467468
...payload,
468-
messages: trigger === "submit-message" ? messages.slice(-1) : messages,
469+
messages: slicedMessages,
469470
};
470471

471472
const sendChatMessages = async (token: string) => {

references/ai-chat/src/components/chat.tsx

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useChat } from "@ai-sdk/react";
4+
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
45
import type { ChatUiMessage } from "@/lib/chat-tools";
56
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
67
import type { CompactionChunkData } from "@trigger.dev/sdk/ai";
@@ -9,7 +10,15 @@ import { useCallback, useEffect, useRef, useState } from "react";
910
import { Streamdown } from "streamdown";
1011
import { MODEL_OPTIONS } from "@/lib/models";
1112

12-
function ToolInvocation({ part }: { part: any }) {
13+
function ToolInvocation({
14+
part,
15+
onApprove,
16+
onDeny,
17+
}: {
18+
part: any;
19+
onApprove?: (approvalId: string) => void;
20+
onDeny?: (approvalId: string) => void;
21+
}) {
1322
const [expanded, setExpanded] = useState(false);
1423
const toolName = part.type.startsWith("tool-") ? part.type.slice(5) : "tool";
1524
const state = part.state ?? "input-available";
@@ -18,6 +27,9 @@ function ToolInvocation({ part }: { part: any }) {
1827

1928
const isLoading = state === "input-streaming" || state === "input-available";
2029
const isError = state === "output-error";
30+
const needsApproval = state === "approval-requested";
31+
const wasApproved = state === "approval-responded" && part.approval?.approved === true;
32+
const wasDenied = state === "approval-responded" && part.approval?.approved === false;
2133

2234
return (
2335
<div className="my-1 rounded border border-gray-200 bg-gray-50 text-xs">
@@ -29,12 +41,37 @@ function ToolInvocation({ part }: { part: any }) {
2941
{isLoading && (
3042
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
3143
)}
32-
{!isLoading && !isError && <span className="text-green-600">&#10003;</span>}
44+
{needsApproval && <span className="text-amber-500">&#9888;</span>}
45+
{wasApproved && <span className="text-green-600">&#10003;</span>}
46+
{wasDenied && <span className="text-red-600">&#10007;</span>}
47+
{!isLoading && !needsApproval && !wasApproved && !wasDenied && !isError && (
48+
<span className="text-green-600">&#10003;</span>
49+
)}
3350
{isError && <span className="text-red-600">&#10007;</span>}
3451
<span>{toolName}</span>
52+
{needsApproval && <span className="text-amber-500 text-[10px]">needs approval</span>}
3553
<span className="ml-auto text-gray-400">{expanded ? "▲" : "▼"}</span>
3654
</button>
3755

56+
{needsApproval && (
57+
<div className="flex gap-2 border-t border-gray-200 px-3 py-2">
58+
<button
59+
type="button"
60+
onClick={() => onApprove?.(part.approval.id)}
61+
className="rounded bg-green-600 px-3 py-1 text-xs font-medium text-white hover:bg-green-700"
62+
>
63+
Approve
64+
</button>
65+
<button
66+
type="button"
67+
onClick={() => onDeny?.(part.approval.id)}
68+
className="rounded bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700"
69+
>
70+
Deny
71+
</button>
72+
</div>
73+
)}
74+
3875
{expanded && (
3976
<div className="border-t border-gray-200 px-3 py-2 space-y-2">
4077
{args && Object.keys(args).length > 0 && (
@@ -267,11 +304,20 @@ export function Chat({
267304
const turnCounter = useRef(0);
268305
const [ttfbHistory, setTtfbHistory] = useState<TtfbEntry[]>([]);
269306

270-
const { messages, setMessages, sendMessage, stop: aiStop, status, error } = useChat({
307+
const {
308+
messages,
309+
setMessages,
310+
sendMessage,
311+
stop: aiStop,
312+
addToolApprovalResponse,
313+
status,
314+
error,
315+
} = useChat({
271316
id: chatId,
272317
messages: initialMessages,
273318
transport,
274319
resume: resumeProp,
320+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
275321
});
276322

277323
// Use transport.stopGeneration for reliable stop after reconnect.
@@ -282,6 +328,21 @@ export function Chat({
282328
aiStop();
283329
}, [transport, chatId, aiStop]);
284330

331+
// Tool approval callbacks
332+
const handleApprove = useCallback(
333+
(approvalId: string) => {
334+
addToolApprovalResponse({ id: approvalId, approved: true });
335+
},
336+
[addToolApprovalResponse, chatId, messages, status]
337+
);
338+
339+
const handleDeny = useCallback(
340+
(approvalId: string) => {
341+
addToolApprovalResponse({ id: approvalId, approved: false, reason: "User denied" });
342+
},
343+
[addToolApprovalResponse, chatId]
344+
);
345+
285346
// Notify parent of first user message (for chat metadata creation)
286347
useEffect(() => {
287348
if (hasCalledFirstMessage.current) return;
@@ -549,7 +610,14 @@ export function Chat({
549610
}
550611

551612
if (part.type.startsWith("tool-")) {
552-
return <ToolInvocation key={i} part={part} />;
613+
return (
614+
<ToolInvocation
615+
key={i}
616+
part={part}
617+
onApprove={handleApprove}
618+
onDeny={handleDeny}
619+
/>
620+
);
553621
}
554622

555623
if (pending.isInjectionPoint(part)) {

references/ai-chat/src/lib/chat-tools.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,22 @@ export const executeJs = tool({
308308
},
309309
});
310310

311+
export const sendEmail = tool({
312+
description:
313+
"Send an email to a recipient. Requires human approval before sending. " +
314+
"Use when the user asks you to send, draft, or compose an email.",
315+
inputSchema: z.object({
316+
to: z.string().describe("Recipient email address"),
317+
subject: z.string().describe("Email subject line"),
318+
body: z.string().describe("Email body text"),
319+
}),
320+
needsApproval: true,
321+
execute: async ({ to, subject, body }) => {
322+
// Simulated — in a real app this would call an email API
323+
return { sent: true, to, subject, preview: body.slice(0, 100) };
324+
},
325+
});
326+
311327
/** Tool set passed to `streamText` for the main `chat.agent` run (includes PostHog). */
312328
export const chatTools = {
313329
inspectEnvironment,
@@ -316,6 +332,7 @@ export const chatTools = {
316332
posthogQuery,
317333
executeCode,
318334
executeJs,
335+
sendEmail,
319336
};
320337

321338
type ChatToolSet = typeof chatTools;

0 commit comments

Comments
 (0)