From 237b6cb119fc4f0ca32dbf62cdeb97608b700b05 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 10 Aug 2026 15:24:01 +0200 Subject: [PATCH] fix assistant tool result streaming --- apps/docs/docs/management/assistant.mdx | 3 + .../chat/assistant-message-conversion.spec.ts | 48 ++++++++++++++ .../chat/assistant-message-conversion.ts | 9 +++ .../chat/assistant-tool-loop.spec.ts | 65 +++++++++++++++++++ .../assistant/chat/assistant-tool-output.ts | 9 +++ .../src/app/api/assistant/chat/route.ts | 19 ++---- 6 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.spec.ts create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.ts create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index a65208956..aecf20fd5 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -212,6 +212,9 @@ multi-item dashboard changes normally finish without requiring a follow-up “Co Homarr tools sequentially to prevent routed models from interleaving or truncating parallel JSON arguments, while every mutation still pauses for its normal approval. A safely recoverable truncated icon-search object is repaired before validation; incomplete search strings and all mutation inputs are never guessed. +Homarr also normalizes internal tool results to the model's JSON-compatible message format before continuing an +agent loop. Rich server values such as dates cannot invalidate the next model step, and an interrupted tool call +without a result is omitted when the conversation resumes instead of making the entire prompt fail. The assistant can create a formatted dashboard note by adding a **Notebook** widget. Ask it for a note with headings, lists, tasks, links, emphasis, tables, or images; it generates compatible rich-text content, shows diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.spec.ts new file mode 100644 index 000000000..b1231f6d1 --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import type { UIMessage } from "ai"; + +import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion"; + +describe("convertAssistantMessagesToModelMessages", () => { + test("drops an interrupted tool call while preserving completed tool results", async () => { + const messages: UIMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Find Homarr icons." }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-icon_findIcons", + toolCallId: "cancelled-call", + state: "input-available", + input: { searchText: "homarr" }, + }, + { + type: "tool-board_getBoardSettings", + toolCallId: "completed-call", + state: "output-available", + input: { id: "board-1" }, + output: { id: "board-1", name: "Home" }, + }, + ], + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Continue." }], + }, + ]; + + const result = await convertAssistantMessagesToModelMessages(messages); + const serialized = JSON.stringify(result); + + expect(serialized).not.toContain("cancelled-call"); + expect(serialized).toContain("completed-call"); + expect(serialized).toContain('"name":"Home"'); + expect(result.at(-1)).toEqual({ role: "user", content: [{ type: "text", text: "Continue." }] }); + }); +}); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.ts new file mode 100644 index 000000000..8043fd0c3 --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.ts @@ -0,0 +1,9 @@ +import { convertToModelMessages } from "ai"; +import type { UIMessage } from "ai"; + +export const convertAssistantMessagesToModelMessages = (messages: UIMessage[]) => + convertToModelMessages(messages, { + // An aborted response can leave a streamed tool call in conversation history without a + // result. Replaying it makes several OpenAI-compatible providers reject the whole prompt. + ignoreIncompleteToolCalls: true, + }); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-loop.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-loop.spec.ts index a1351b61b..be13e0c01 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-loop.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-loop.spec.ts @@ -3,6 +3,7 @@ import { jsonSchema, simulateReadableStream, stepCountIs, streamText, tool } fro import { MockLanguageModelV4 } from "ai/test"; import { repairAssistantToolInput } from "./assistant-tool-input-repair"; +import { toAssistantToolOutput } from "./assistant-tool-output"; const usage = { inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 }, @@ -167,4 +168,68 @@ describe("Assistant tool loop", () => { expect.objectContaining({ toolCallId: "icon-call" }), ); }); + + test("normalizes rich tRPC results before the next model step", async () => { + const streamErrors = vi.fn(); + const inspect = vi.fn(() => + toAssistantToolOutput({ + updatedAt: new Date("2026-08-10T00:00:00.000Z"), + optional: undefined, + exactCount: 28_154n, + groups: new Map([["icons", new Set(["homarr.svg", "homarr.png"])]]), + }), + ); + const model = new MockLanguageModelV4({ + doStream: [ + { + stream: simulateReadableStream({ + chunks: [ + { type: "stream-start" as const, warnings: [] }, + { + type: "tool-call" as const, + toolCallId: "inspect-call", + toolName: "inspect", + input: "{}", + }, + finish("tool-calls"), + ], + chunkDelayInMs: null, + }), + }, + { + stream: simulateReadableStream({ + chunks: [ + { type: "stream-start" as const, warnings: [] }, + { type: "text-start" as const, id: "answer" }, + { type: "text-delta" as const, id: "answer", delta: "The rich result is valid." }, + { type: "text-end" as const, id: "answer" }, + finish("stop"), + ], + chunkDelayInMs: null, + }), + }, + ], + }); + const result = streamText({ + model, + prompt: "Inspect rich Homarr data.", + tools: { + inspect: tool({ + inputSchema: jsonSchema({ type: "object", properties: {}, additionalProperties: false }), + execute: inspect, + }), + }, + stopWhen: stepCountIs(2), + onError: streamErrors, + }); + + await expect(result.text).resolves.toBe("The rich result is valid."); + expect(streamErrors).not.toHaveBeenCalled(); + expect(inspect).toHaveBeenCalledTimes(1); + const nextPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt); + expect(nextPrompt).toContain('"updatedAt":"2026-08-10T00:00:00.000Z"'); + expect(nextPrompt).toContain('"optional":null'); + expect(nextPrompt).toContain('"exactCount":"28154"'); + expect(nextPrompt).toContain('"groups":[["icons",["homarr.svg","homarr.png"]]]'); + }); }); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.ts new file mode 100644 index 000000000..4ee6fc9e8 --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.ts @@ -0,0 +1,9 @@ +import { serialize } from "superjson"; + +/** + * AI SDK model messages only accept JSON-compatible tool results. Homarr's tRPC callers can + * return richer values such as Date, bigint, Map, Set, NaN, and nested undefined values. + * SuperJSON's transport representation normalizes those values without restoring their runtime + * types, keeping subsequent agent steps valid while preserving the useful data. + */ +export const toAssistantToolOutput = (value: unknown) => serialize(value as Parameters[0]).json; diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 3d583dbf8..b7a0c9adc 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -3,16 +3,7 @@ import { hkdfSync } from "node:crypto"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { MetadataExtractor } from "@ai-sdk/openai-compatible"; import type { ToolSet, UIMessage } from "ai"; -import { - convertToModelMessages, - createUIMessageStream, - createUIMessageStreamResponse, - jsonSchema, - stepCountIs, - streamText, - tool, -} from "ai"; -import { parse, stringify } from "superjson"; +import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai"; import { z } from "zod/v4"; import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; @@ -46,6 +37,7 @@ import type { import { extractMcpTools } from "../../mcp/_extract-tools"; import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input"; import { getAssistantModelLookupStatus } from "./assistant-model-lookup"; +import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion"; import { getOpenRouterWebSearchRequests, getOpenRouterWebSearchSources, @@ -58,6 +50,7 @@ import { assistantExecutionPolicy } from "./assistant-execution-policy"; import { getAssistantStreamErrorMessage } from "./assistant-stream-error"; import { getSafeAssistantToolError } from "./assistant-tool-error"; import { repairAssistantToolInput } from "./assistant-tool-input-repair"; +import { toAssistantToolOutput } from "./assistant-tool-output"; import { createCustomWidgetDynamicContextController, getCustomWidgetAuthoringContext, @@ -509,7 +502,7 @@ export async function POST(request: Request) { const result = await (procedure as (value: unknown) => Promise)( input && Object.keys(input as object).length > 0 ? input : undefined, ); - return parse(stringify(result)); + return toAssistantToolOutput(result); } catch (error) { logger.error("Assistant tool call failed", { toolName: mcpTool.name, @@ -587,7 +580,9 @@ export async function POST(request: Request) { let hasReportedCost = false; let upstreamCost = 0; let hasUpstreamCost = false; - const initialModelMessages = await convertToModelMessages(prepareMessagesForModel(incomingMessages)); + const initialModelMessages = await convertAssistantMessagesToModelMessages( + prepareMessagesForModel(incomingMessages), + ); const modelMessages = customWidgetAuthoringContext.systemContext.length > 0 ? prunePreloadedCustomWidgetModelMessages(initialModelMessages)