diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index ac52197803..23eaf88545 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -186,7 +186,37 @@ export async function POST(request: Request) { const isReasoningModel = capabilities?.reasoning === true; const supportsTools = capabilities?.tools === true; - const modelMessages = await convertToModelMessages(uiMessages); + // Clean up unhandled tool approvals: strip pending parts from model context and mark them as denied in DB + const isPendingApproval = (part: Record) => + part.state === "approval-requested"; + + const cleanedMessages = uiMessages + .map((msg) => { + if (msg.role !== "assistant") return msg; + const cleanedParts = msg.parts.filter( + (part) => !isPendingApproval(part as Record), + ); + if (cleanedParts.length === msg.parts.length) return msg; + return { ...msg, parts: cleanedParts }; + }) + .filter((msg) => msg.role !== "assistant" || msg.parts.length > 0); + + + // Sync changes to the database + await Promise.all( + messagesFromDb.map(async (dbMsg) => { + const parts = dbMsg.parts as Record[]; + if (!parts.some(isPendingApproval)) return; + await updateMessage({ + id: dbMsg.id, + parts: parts.map((p) => + isPendingApproval(p) ? { ...p, state: "output-denied" } : p, + ) as DBMessage["parts"], + }); + }), + ); + + const modelMessages = await convertToModelMessages(cleanedMessages); const stream = createUIMessageStream({ originalMessages: isToolApprovalFlow ? uiMessages : undefined, diff --git a/hooks/use-active-chat.tsx b/hooks/use-active-chat.tsx index 85082a9f36..e9af3e9bec 100644 --- a/hooks/use-active-chat.tsx +++ b/hooks/use-active-chat.tsx @@ -6,6 +6,7 @@ import { DefaultChatTransport } from "ai"; import { usePathname } from "next/navigation"; import { createContext, + useCallback, type Dispatch, type ReactNode, type SetStateAction, @@ -117,8 +118,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { (part) => "state" in part && part.state === "approval-responded" && - "approval" in part && - (part.approval as { approved?: boolean })?.approved === true + "approval" in part ) ?? false ); }, @@ -128,7 +128,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { prepareSendMessagesRequest(request) { const lastMessage = request.messages.at(-1); const isToolApprovalContinuation = - lastMessage?.role !== "user" || + lastMessage?.role !== "user" && request.messages.some((msg) => msg.parts?.some((part) => { const state = (part as { state?: string }).state; @@ -244,12 +244,47 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { { revalidateOnFocus: false } ); + // Automatically change all messages still in the 'approval-requested' state to 'output-denied' when sending a new message. + // Ensure the user can continue chatting even without explicit approval (neither 'Allow' nor 'Deny'). + const wrappedSendMessage: typeof sendMessage = useCallback( + (...args) => { + setMessages((prev) => + prev.map((msg) => { + if (msg.role !== "assistant") return msg; + + const hasPending = msg.parts.some( + (part) => + "state" in part && + (part as Record).state === "approval-requested", + ); + if (!hasPending) return msg; + + return { + ...msg, + parts: msg.parts.map((part) => { + if ( + "state" in part && + (part as Record).state === "approval-requested" + ) { + return { ...part, state: "output-denied" } as typeof part; + } + return part; + }), + }; + }), + ); + + return sendMessage(...args); + }, + [sendMessage, setMessages], + ); + const value = useMemo( () => ({ chatId, messages, setMessages, - sendMessage, + sendMessage: wrappedSendMessage, status, stop, regenerate, @@ -269,7 +304,7 @@ export function ActiveChatProvider({ children }: { children: ReactNode }) { chatId, messages, setMessages, - sendMessage, + wrappedSendMessage, status, stop, regenerate,