fix assistant tool continuation

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent f4f6773f26
commit bb6e8e5c5a
13 files changed
+469 -11

No files matched your search

+5 -2
View File
@@ -129,7 +129,9 @@ Fenced `mermaid` blocks render as diagrams in the current color scheme using Mer
invalid diagrams fall back to their source instead of breaking the response.
When Homarr's icon search returns an image URL, visual result tables render that actual icon in a compact
image column instead of substituting an emoji. Assistant-provided Markdown images accept safe relative,
HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer.
HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer. Image message parts
render through assistant-ui's image primitive in a bounded responsive preview with an accessible loading state,
a filename caption when available, and an in-place retry when the source fails.
Images up to 1 MB and text, Markdown, CSV, HTML, XML, or JSON documents up to 350 KB can be attached by
using the paperclip, dropping files onto the composer, or pasting an image with `Cmd/Ctrl + V`. Attachment
previews stay local to the composer where they were added and show upload state, file type, and a removable image
@@ -192,7 +194,8 @@ the assistant does not require a second typed confirmation first. Tool cards exp
validated input, result, and error state without flooding the conversation with raw JSON by default.
Common collection and widget results are summarized inline as compact responsive cards, while the full
structured result remains available in the tool card details. Icon searches show the returned image variants,
file formats, and repositories instead of the size of the complete icon catalog.
file formats, and repositories instead of the size of the complete icon catalog. Their trace uses a readable
activity such as **Looking for “homarr” icons** instead of exposing the internal MCP procedure name.
Large tool-driven requests can use up to 32 agent steps and a larger response budget within one request, so
multi-item dashboard changes normally finish without requiring a follow-up “Continue” message. Independent
@@ -0,0 +1,103 @@
import { describe, expect, test, vi } from "vitest";
import { jsonSchema, simulateReadableStream, stepCountIs, streamText, tool } from "ai";
import { MockLanguageModelV4 } from "ai/test";
const usage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
const finish = (unified: "stop" | "tool-calls") => ({
type: "finish" as const,
finishReason: { unified, raw: unified },
usage,
});
describe("Assistant tool loop", () => {
test("keeps a valid icon search alongside another agent tool result", async () => {
const findIcons = vi.fn(() => ({
countIcons: 1,
icons: [
{
slug: "homarr-labs/dashboard-icons",
icons: [{ name: "homarr.svg", url: "https://cdn.example.com/homarr.svg" }],
},
],
}));
const getBoardSettings = vi.fn(() => ({ id: "board-1", name: "Home" }));
const model = new MockLanguageModelV4({
doStream: [
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{
type: "tool-call" as const,
toolCallId: "icon-call",
toolName: "icon_findIcons",
input: '{"searchText":"homarr"}',
},
{
type: "tool-call" as const,
toolCallId: "board-call",
toolName: "board_getBoardSettings",
input: '{"id":"board-1"}',
},
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: "Both tools completed." },
{ type: "text-end" as const, id: "answer" },
finish("stop"),
],
chunkDelayInMs: null,
}),
},
],
});
const result = streamText({
model,
prompt: "Find the Homarr icon and inspect my board.",
tools: {
icon_findIcons: tool({
inputSchema: jsonSchema({
type: "object",
properties: {
searchText: { type: "string" },
limitPerGroup: { type: "number", minimum: 1, maximum: 500, default: 12 },
},
additionalProperties: false,
}),
execute: findIcons,
}),
board_getBoardSettings: tool({
inputSchema: jsonSchema({
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
}),
execute: getBoardSettings,
}),
},
stopWhen: stepCountIs(2),
});
await expect(result.text).resolves.toBe("Both tools completed.");
expect(findIcons).toHaveBeenCalledWith(
{ searchText: "homarr" },
expect.objectContaining({ toolCallId: "icon-call" }),
);
expect(getBoardSettings).toHaveBeenCalledTimes(1);
const nextPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
expect(nextPrompt).toContain("homarr.svg");
expect(nextPrompt).toContain('"id":"board-1"');
});
});
@@ -55,4 +55,36 @@ describe("shouldAutomaticallyContinueAssistant", () => {
}),
).toBe(false);
});
test("continues a completed browser-tool step without treating an earlier agent step as user input", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: [
{
id: "assistant-message",
role: "assistant",
parts: [
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "icon_findIcons",
toolCallId: "icon-1",
input: { searchText: "homarr" },
state: "input-available",
},
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "refresh_current_view",
toolCallId: "refresh-1",
input: {},
state: "output-available",
output: { success: true },
},
],
},
],
}),
).toBe(true);
});
});
@@ -0,0 +1,69 @@
.assistantImage {
width: fit-content;
max-width: min(100%, 28rem);
margin: 0.65rem 0;
}
.assistantImageViewport {
position: relative;
display: grid;
min-width: min(12rem, 100%);
min-height: 6rem;
overflow: hidden;
place-items: center;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background:
linear-gradient(45deg, var(--mantine-color-default) 25%, transparent 25%),
linear-gradient(-45deg, var(--mantine-color-default) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--mantine-color-default) 75%),
linear-gradient(-45deg, transparent 75%, var(--mantine-color-default) 75%);
background-position:
0 0,
0 0.45rem,
0.45rem -0.45rem,
-0.45rem 0;
background-size: 0.9rem 0.9rem;
}
.assistantImageViewport > img {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 24rem;
margin: 0;
border-radius: 0;
object-fit: contain;
}
.assistantImageStatus {
position: absolute;
inset: 0;
min-height: 6rem;
color: var(--mantine-color-dimmed);
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
}
.assistantImageCaption {
max-width: 100%;
padding: 0.3rem 0.15rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(td) .assistantImage {
width: 2rem;
margin: 0 auto;
}
:global(td) .assistantImageViewport,
:global(td) .assistantImageStatus {
min-width: 2rem;
min-height: 2rem;
}
:global(td) .assistantImageViewport {
border-radius: var(--mantine-radius-sm);
}
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { AssistantImage } from "./assistant-image";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const containers: HTMLDivElement[] = [];
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
});
afterEach(() => {
for (const container of containers.splice(0)) container.remove();
});
const renderImage = async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(
MantineProvider,
undefined,
createElement(AssistantImage, {
source: "https://cdn.example.com/homarr.png",
alt: "Homarr icon",
loadingLabel: "Loading image",
failedLabel: "Image could not be loaded",
retryLabel: "Retry image",
}),
),
);
});
return { container, root };
};
describe("AssistantImage", () => {
test("shows an accessible loading state and reveals the responsive image after loading", async () => {
const { container, root } = await renderImage();
expect(container.querySelector("output")?.getAttribute("aria-label")).toBe("Loading image");
const image = container.querySelector("img");
expect(image?.getAttribute("alt")).toBe("Homarr icon");
expect(image?.getAttribute("referrerpolicy")).toBe("no-referrer");
await act(async () => image?.dispatchEvent(new Event("load")));
expect(container.querySelector("output")).toBeNull();
expect(container.querySelector("figure")?.getAttribute("data-image-state")).toBe("loaded");
await act(async () => root.unmount());
});
test("replaces a broken image with a recoverable error and retries it", async () => {
const { container, root } = await renderImage();
const firstImage = container.querySelector("img") as HTMLImageElement;
await act(async () => firstImage.dispatchEvent(new Event("error")));
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Image could not be loaded");
const retry = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Retry image"),
);
await act(async () => retry?.click());
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector("output")).not.toBeNull();
expect(container.querySelector("img")).not.toBe(firstImage);
await act(async () => root.unmount());
});
});
@@ -0,0 +1,97 @@
"use client";
import type { ComponentPropsWithoutRef } from "react";
import { useEffect, useState } from "react";
import { MessagePartPrimitive } from "@assistant-ui/react";
import { Box, Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconPhotoOff, IconRefresh } from "@tabler/icons-react";
import classes from "./assistant-image.module.css";
interface AssistantImageProps extends Omit<ComponentPropsWithoutRef<"img">, "src"> {
source: string;
messagePart?: boolean;
caption?: string;
loadingLabel: string;
failedLabel: string;
retryLabel: string;
}
/**
* A bounded image preview shared by Markdown and assistant-ui image message parts. Message parts
* deliberately render through MessagePartPrimitive.Image so their source remains connected to the
* assistant-ui part context; the sanitized source prop overrides the raw part value.
*/
export const AssistantImage = ({
source,
messagePart = false,
caption,
loadingLabel,
failedLabel,
retryLabel,
alt,
className,
...imageProps
}: AssistantImageProps) => {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<"loading" | "loaded" | "failed">("loading");
useEffect(() => {
setState("loading");
setAttempt(0);
}, [source]);
const sharedProps = {
...imageProps,
src: source,
className,
loading: "lazy" as const,
decoding: "async" as const,
referrerPolicy: "no-referrer" as const,
onLoad: () => setState("loaded"),
onError: () => setState("failed"),
};
return (
<Box component="figure" className={classes.assistantImage} data-image-state={state}>
<Box className={classes.assistantImageViewport}>
{messagePart ? (
<MessagePartPrimitive.Image key={attempt} {...sharedProps} alt={alt} />
) : (
<img key={attempt} {...sharedProps} alt={alt} />
)}
{state === "loading" && (
<Center component="output" className={classes.assistantImageStatus} aria-label={loadingLabel}>
<Loader type="bars" size="sm" />
</Center>
)}
{state === "failed" && (
<Center className={classes.assistantImageStatus} role="alert">
<Stack align="center" gap={6} p="sm">
<IconPhotoOff size={22} aria-hidden />
<Text size="xs" ta="center">
{failedLabel}
</Text>
<Button
size="compact-xs"
variant="default"
leftSection={<IconRefresh size={13} aria-hidden />}
onClick={() => {
setState("loading");
setAttempt((value) => value + 1);
}}
>
{retryLabel}
</Button>
</Stack>
</Center>
)}
</Box>
{caption && state === "loaded" && (
<Text component="figcaption" className={classes.assistantImageCaption} size="xs" c="dimmed">
{caption}
</Text>
)}
</Box>
);
};
@@ -124,6 +124,7 @@ import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { AssistantImage } from "./assistant-image";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
@@ -142,6 +143,7 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
import { useAssistantReasoningState } from "./assistant-reasoning-state";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
import { getToolResultPresentation } from "./assistant-tool-result";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
import { getSafeAssistantHttpUrl } from "./assistant-url";
@@ -267,18 +269,19 @@ const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">
);
const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => {
const t = useScopedI18n("common.assistant.image");
const safeSource = getSafeAssistantMarkdownImageSource(src);
if (!safeSource) return null;
return (
<img
<AssistantImage
{...props}
src={safeSource}
source={safeSource}
alt={alt}
className={classes.markdownImage}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
loadingLabel={t("loading")}
failedLabel={t("failed")}
retryLabel={t("retry")}
/>
);
};
@@ -623,7 +626,18 @@ const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
</Group>
);
}
return <Box component="img" src={source} alt={filename ?? t("attachedImage")} className={classes.messageImage} />;
return (
<AssistantImage
messagePart
source={source}
alt={filename ?? t("attachedImage")}
caption={filename}
className={classes.messageImage}
loadingLabel={t("image.loading")}
failedLabel={t("image.failed")}
retryLabel={t("image.retry")}
/>
);
};
const formatToolResultValue = (value: string | number | boolean) =>
@@ -835,6 +849,13 @@ const ToolPart = ({
const successful = completed && !denied && !failed;
const compactPresentation = compact && !awaitingApproval;
const traceTarget = getAssistantToolTraceTarget(args);
const iconSearchQuery = getAssistantIconSearchQuery(toolName, args);
const displayName =
iconSearchQuery === null
? toolName.replaceAll("_", " ")
: iconSearchQuery.length > 0
? t("toolActivity.iconSearch", { query: iconSearchQuery })
: t("toolActivity.iconBrowse");
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
const autoApprovalInProgress = useAssistantAutomaticAction({
toolCallId,
@@ -865,7 +886,7 @@ const ToolPart = ({
</ThemeIcon>
<Box miw={0}>
<Text size={compactPresentation ? "xs" : "sm"} fw={650} lineClamp={1}>
{toolName.replaceAll("_", " ")}
{displayName}
</Text>
{compactPresentation && traceTarget && (
<Text size="xs" c="dimmed" lineClamp={1}>
@@ -12,7 +12,8 @@ import type {
Toolkit,
} from "@assistant-ui/react";
import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { useChat } from "@ai-sdk/react";
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { useHotkeys } from "@mantine/hooks";
import { createAssistantStream } from "assistant-stream";
@@ -36,6 +37,7 @@ import { AssistantPanel } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
import {
AssistantComposerSurfaceProvider,
AssistantRunFocusPreserver,
@@ -321,6 +323,8 @@ const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapte
const AssistantThreadRuntime = () => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const localThreadId = useAuiState((state) => state.threadListItem.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const preferences = useAssistantPreferences();
const transport = useMemo(
@@ -364,16 +368,25 @@ const AssistantThreadRuntime = () => {
[t],
);
return useChatRuntime<AssistantUIMessage>({
const chat = useChat<AssistantUIMessage>({
id: localThreadId,
transport,
onError,
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
});
const runtime = useAISDKRuntime(chat, {
...assistantAiSdkRuntimeOptions,
adapters: {
history,
attachments,
feedback,
},
});
transport.setRuntime(runtime);
// oxlint-disable-next-line no-underscore-dangle -- assistant-ui requires this hook to resolve the persisted remote thread before sending.
transport.__internal_setGetThreadListItem(() => (aui.threadListItem.source ? aui.threadListItem() : undefined));
return runtime;
};
const AssistantRuntime = ({ children }: PropsWithChildren) => {
@@ -0,0 +1,9 @@
import { describe, expect, test } from "vitest";
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
describe("assistantAiSdkRuntimeOptions", () => {
test("preserves pending server tools across automatic agent continuations", () => {
expect(assistantAiSdkRuntimeOptions.cancelPendingToolCallsOnSend).toBe(false);
});
});
@@ -0,0 +1,5 @@
export const assistantAiSdkRuntimeOptions = {
// Automatic frontend-tool continuations are sends too. Letting assistant-ui cancel every
// pending tool on send incorrectly turns earlier server tools into user cancellations.
cancelPendingToolCallsOnSend: false,
} as const;
@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
describe("getAssistantIconSearchQuery", () => {
test("extracts and trims the streamed icon search target", () => {
expect(getAssistantIconSearchQuery("icon_findIcons", { searchText: " homarr " })).toBe("homarr");
});
test("distinguishes browsing all icons from unrelated tools", () => {
expect(getAssistantIconSearchQuery("icon_findIcons", {})).toBe("");
expect(getAssistantIconSearchQuery("app_create", { searchText: "homarr" })).toBeNull();
});
});
@@ -0,0 +1,8 @@
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
export const getAssistantIconSearchQuery = (toolName: string, args: unknown) => {
if (toolName !== "icon_findIcons") return null;
const searchText = asRecord(args)?.searchText;
return typeof searchText === "string" && searchText.trim().length > 0 ? searchText.trim() : "";
};
+1
View File
@@ -3,6 +3,7 @@ packages:
- packages/*
- tooling/*
catalog:
"@ai-sdk/react": ^4.0.55
"@babel/parser": ^7.29.7
"@codemirror/autocomplete": ^6.20.3
"@codemirror/commands": ^6.10.3