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

@@ -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() : "";
};