fix(FN-7143): support task image artifacts

Fusion-Task-Id: FN-7143
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 08:54:18 -07:00
parent 05c54fbe29
commit 353aaf3b4a
7 changed files with 339 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Allow task image artifacts to be created from agent tools and viewed in task details.
category: fix
dev: Adds `dataBase64` support to `fn_artifact_register` and task-detail image preview expansion.

View File

@@ -54,7 +54,7 @@ These tools are **not** part of the user-invokable extension surface. They are i
| `fn_send_message` | executor, step-session, heartbeat | Send inbox messages to agents/users | `to_id` (string), `content` (string), `type?` (`agent-to-agent` \| `agent-to-user`), `reply_to_message_id?` (string) |
| `fn_read_messages` | executor, step-session, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) |
| `fn_post_room_message` | heartbeat | Post a message to a chat room the agent is a member of | `roomId` (string), `content` (string), `replyToMessageId?` (string), `mentions?` (string[]) |
| `fn_artifact_register` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it | `type` (string), `title` (string), `description?` (string), `mimeType?` (string), `uri?` (string), `content?` (string), `taskId?` (string); chat/planning also require `task_id` (string) |
| `fn_artifact_register` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it; image artifacts may provide `dataBase64` bytes for registry-managed media storage | `type` (string), `title` (string), `description?` (string), `mimeType?` (string), `uri?` (string), `content?` (string), `dataBase64?` (base64 string), `taskId?` (string); chat/planning also require `task_id` (string) |
| `fn_artifact_list` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | List registered artifacts across agents and tasks with filters for type, authorId, taskId, search, limit, and offset | `type?` (string), `authorId?` (string), `taskId?` (string), `search?` (string), `limit?` (number), `offset?` (number); chat/planning also require `task_id` (string) |
| `fn_artifact_view` | triage, executor, heartbeat | View a registered artifact by id, including metadata and inline content or the uri/path reference for media artifacts | `id` (string) |

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History } from "lucide-react";
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History, X } from "lucide-react";
import "./DocumentsView.css";
import "./TaskDocumentsTab.css";
import ReactMarkdown from "react-markdown";
@@ -56,9 +56,10 @@ function formatFileSize(bytes: number): string {
interface TaskArtifactCardProps {
artifact: ArtifactWithTask;
projectId?: string;
onExpandImage: (artifact: ArtifactWithTask) => void;
}
function TaskArtifactCard({ artifact, projectId }: TaskArtifactCardProps) {
function TaskArtifactCard({ artifact, projectId, onExpandImage }: TaskArtifactCardProps) {
const { t } = useTranslation("app");
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
const typeLabel = getArtifactTypeLabel(t, artifact.type);
@@ -67,9 +68,21 @@ function TaskArtifactCard({ artifact, projectId }: TaskArtifactCardProps) {
return (
<article className="document-card documents-artifact-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
<div className="documents-artifact-preview">
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
</div>
{artifact.type === "image" ? (
<button
type="button"
className="documents-artifact-preview documents-artifact-preview--expandable"
onClick={() => onExpandImage(artifact)}
aria-label={t("documents.expandImageArtifact", "Expand image artifact {{title}}", { title })}
>
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
<span className="documents-artifact-expand-hint">{t("documents.expandArtifactHint", "Click to expand")}</span>
</button>
) : (
<div className="documents-artifact-preview">
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
</div>
)}
<div className="documents-artifact-body">
<div className="documents-artifact-header">
<span className="documents-artifact-type-badge">{typeLabel}</span>
@@ -110,6 +123,13 @@ export function TaskDocumentsTab({
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [renderMarkdown, setRenderMarkdown] = useState(false);
/*
* FNXC:ArtifactRegistry 2026-06-29-00:00:
* Task detail image artifacts must be viewable in-place from the task modal. Keep the expand target image-only so document, audio, video, and generic cards retain their current non-lightbox behavior without empty controls.
*/
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactWithTask | null>(null);
const lightboxCloseRef = useRef<HTMLButtonElement>(null);
const lightboxReturnFocusRef = useRef<HTMLElement | null>(null);
const { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId });
const loadDocuments = useCallback(async () => {
@@ -266,6 +286,46 @@ export function TaskDocumentsTab({
setEditContent("");
}
const handleExpandArtifactImage = useCallback((artifact: ArtifactWithTask) => {
lightboxReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setLightboxArtifact(artifact);
}, []);
const handleCloseLightbox = useCallback(() => {
setLightboxArtifact(null);
lightboxReturnFocusRef.current?.focus();
lightboxReturnFocusRef.current = null;
}, []);
useEffect(() => {
if (!lightboxArtifact) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
lightboxCloseRef.current?.focus();
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
handleCloseLightbox();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", handleKeyDown);
};
}, [handleCloseLightbox, lightboxArtifact]);
const handleLightboxOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
handleCloseLightbox();
}
}, [handleCloseLightbox]);
if (loading || artifactsLoading) {
return (
<div className="detail-section">
@@ -299,7 +359,7 @@ export function TaskDocumentsTab({
</div>
<div className="documents-artifact-gallery documents-artifact-gallery--mobile task-artifacts-gallery">
{artifacts.map((artifact) => (
<TaskArtifactCard key={artifact.id} artifact={artifact} projectId={projectId} />
<TaskArtifactCard key={artifact.id} artifact={artifact} projectId={projectId} onExpandImage={handleExpandArtifactImage} />
))}
</div>
</section>
@@ -549,6 +609,32 @@ export function TaskDocumentsTab({
</button>
)}
</section>
{lightboxArtifact && (
<div
className="modal-overlay open documents-artifact-lightbox-overlay"
role="dialog"
aria-modal="true"
aria-label={t("documents.lightboxLabel", "Artifact media preview")}
onClick={handleLightboxOverlayClick}
>
<div className="documents-artifact-lightbox" onClick={(event) => event.stopPropagation()}>
<div className="documents-artifact-lightbox-header">
<h3 className="documents-artifact-lightbox-title">{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}</h3>
<button ref={lightboxCloseRef} className="modal-close" onClick={handleCloseLightbox} aria-label={t("documents.closeLightbox", "Close artifact preview")}>
<X size={20} />
</button>
</div>
<div className="documents-artifact-lightbox-media-frame">
<img
className="documents-artifact-lightbox-media"
src={artifactMediaUrl(lightboxArtifact.id, projectId)}
alt={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -191,6 +191,8 @@ describe("TaskDocumentsTab", () => {
});
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
expect(screen.getByRole("button", { name: "Expand image artifact Image artifact" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Expand image artifact Video artifact/ })).not.toBeInTheDocument();
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO");
expect(screen.getByTestId("artifact-document-preview")).toHaveTextContent("Inline document preview");
@@ -202,6 +204,54 @@ describe("TaskDocumentsTab", () => {
expect(mockArtifactMediaUrl).toHaveBeenCalledWith("artifact-image", "project-1");
});
it("opens image artifacts in a task-detail lightbox and restores focus on close", async () => {
mockFetchTaskDocuments.mockResolvedValue([]);
mockUseArtifacts.mockReturnValue({
artifacts: mockArtifacts,
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
const expandButton = await screen.findByRole("button", { name: "Expand image artifact Image artifact" });
expandButton.focus();
fireEvent.click(expandButton);
const dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
expect(dialog).toBeInTheDocument();
expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" }));
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
});
expect(expandButton).toHaveFocus();
});
it("closes the image artifact lightbox with Escape", async () => {
mockFetchTaskDocuments.mockResolvedValue([]);
mockUseArtifacts.mockReturnValue({
artifacts: mockArtifacts,
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
fireEvent.click(await screen.findByRole("button", { name: "Expand image artifact Image artifact" }));
expect(screen.getByRole("dialog", { name: "Artifact media preview" })).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
});
});
it("surfaces artifact fetch errors", async () => {
mockUseArtifacts.mockReturnValue({
artifacts: [],

View File

@@ -84,6 +84,44 @@ describe("artifacts route integration", () => {
expect(Buffer.from(res.body as string, "utf8")).toEqual(imageBytes);
});
it("a global image artifact still streams from the managed global artifacts directory", async () => {
const imageBytes = Buffer.from("PNG-FN-7143-global-image-bytes", "utf8");
const artifact = await store.registerArtifact({
type: "image",
title: "Global screenshot",
mimeType: "image/png",
data: imageBytes,
authorId: "agent-7143",
authorType: "agent",
});
const res = await REQUEST(app, "GET", `/api/artifacts/${artifact.id}/media`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("image/png");
expect(Buffer.from(res.body as string, "utf8")).toEqual(imageBytes);
});
it("a URI-only image artifact whose file is missing still returns 404", async () => {
const task = await store.createTask({
title: "Missing screenshot",
description: "Preserve existing missing media semantics",
});
const artifact = await store.registerArtifact({
type: "image",
title: "Missing screenshot",
mimeType: "image/png",
uri: "artifacts/missing.png",
authorId: "agent-7143",
authorType: "agent",
taskId: task.id,
});
const res = await REQUEST(app, "GET", `/api/artifacts/${artifact.id}/media`);
expect(res.status).toBe(404);
});
it("a task with no artifacts lists as empty", async () => {
const task = await store.createTask({
title: "Render screenshot",

View File

@@ -89,6 +89,72 @@ describe("artifact register tool", () => {
vi.clearAllMocks();
});
it("decodes base64 image bytes before calling store.registerArtifact", async () => {
const { store, registerArtifact } = createMockStore();
const imageBytes = Buffer.from("small-png-bytes");
registerArtifact.mockResolvedValue(createMockArtifact({
id: "art-image",
type: "image",
title: "Screenshot",
mimeType: "image/png",
uri: "artifacts/screenshot.png",
content: undefined,
}));
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
const result = await runTool(tool, "call-register-image", {
type: "image",
title: "Screenshot",
mimeType: "image/png",
dataBase64: imageBytes.toString("base64"),
taskId: TASK_ID,
});
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({
type: "image",
title: "Screenshot",
mimeType: "image/png",
taskId: TASK_ID,
content: undefined,
uri: undefined,
data: imageBytes,
}));
expect(getText(result)).toContain("Registered artifact");
expect(getText(result)).not.toContain("ERROR:");
});
it("returns an ERROR response without registering malformed base64 image payloads", async () => {
const { store, registerArtifact } = createMockStore();
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
const result = await runTool(tool, "call-register-invalid-image", {
type: "image",
title: "Broken screenshot",
mimeType: "image/png",
dataBase64: "not valid base64!",
taskId: TASK_ID,
});
expect(registerArtifact).not.toHaveBeenCalled();
expect(getText(result)).toContain("ERROR: Failed to register artifact");
expect(getText(result)).toContain("dataBase64 must be valid base64");
});
it("requires an image MIME type for base64 image registration", async () => {
const { store, registerArtifact } = createMockStore();
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
const result = await runTool(tool, "call-register-missing-mime", {
type: "image",
title: "No MIME screenshot",
dataBase64: Buffer.from("bytes").toString("base64"),
taskId: TASK_ID,
});
expect(registerArtifact).not.toHaveBeenCalled();
expect(getText(result)).toContain("image artifacts registered with dataBase64 require an image/* mimeType");
});
it("calls store.registerArtifact with mapped agent author input", async () => {
const { store, registerArtifact } = createMockStore();
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-register" }));
@@ -344,6 +410,40 @@ describe("chat artifact tools", () => {
]);
});
it("registers with explicit task_id, decoded image bytes, and fixed dashboard-chat author", async () => {
const { store, registerArtifact } = createMockStore();
const imageBytes = Buffer.from("chat-image-bytes");
registerArtifact.mockResolvedValue(createMockArtifact({
id: "art-chat-image",
authorId: "dashboard-chat",
taskId: "FN-3030",
type: "image",
title: "Chat screenshot",
mimeType: "image/png",
uri: "artifacts/chat-screenshot.png",
content: undefined,
}));
const { messageStore } = createMockMessageStore();
const tool = findChatTool("fn_artifact_register", store, messageStore);
const result = await runTool(tool, "call-chat-register-image", {
task_id: "FN-3030",
type: "image",
title: "Chat screenshot",
mimeType: "image/png",
dataBase64: imageBytes.toString("base64"),
});
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({
taskId: "FN-3030",
authorId: "dashboard-chat",
authorType: "agent",
title: "Chat screenshot",
data: imageBytes,
}));
expect(getText(result)).toContain("Registered artifact");
});
it("registers with explicit task_id and fixed dashboard-chat author", async () => {
const { store, registerArtifact } = createMockStore();
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-chat", authorId: "dashboard-chat", taskId: "FN-3030" }));

View File

@@ -123,6 +123,7 @@ export const artifactRegisterParams = Type.Object({
mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })),
uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })),
content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })),
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content and uri when provided." })),
taskId: Type.Optional(Type.String({ description: "Optional associated task ID (e.g. 'FN-001')." })),
});
@@ -146,6 +147,7 @@ export const chatArtifactRegisterParams = Type.Object({
mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })),
uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })),
content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })),
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content and uri when provided." })),
task_id: Type.String({ description: "Associated task ID (e.g. 'FN-001')." }),
});
@@ -1350,7 +1352,7 @@ export function createArtifactRegisterTool(store: TaskStore, authorId: string, m
label: "Register Artifact",
description:
"Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it. " +
"Provide either inline content or a uri/path reference; optionally associate it with a taskId.",
"Provide inline content, a uri/path reference, or dataBase64 image bytes; optionally associate it with a taskId.",
parameters: artifactRegisterParams,
execute: async (_id: string, params: Static<typeof artifactRegisterParams>) => registerArtifactForAgent(store, authorId, params, messageStore),
};
@@ -1397,7 +1399,7 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
name: "fn_artifact_register",
label: "Register Artifact",
description:
"Register an artifact for a specific task so other agents can discover it. Requires task_id and notifies the dashboard inbox best-effort.",
"Register an artifact for a specific task so other agents can discover it. Requires task_id, accepts dataBase64 image bytes, and notifies the dashboard inbox best-effort.",
parameters: chatArtifactRegisterParams,
execute: async (_id: string, params: Static<typeof chatArtifactRegisterParams>) => registerArtifactForAgent(
store,
@@ -1409,6 +1411,7 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
mimeType: params.mimeType,
uri: params.uri,
content: params.content,
dataBase64: params.dataBase64,
taskId: params.task_id,
},
messageStore,
@@ -1439,19 +1442,21 @@ async function registerArtifactForAgent(
params: Static<typeof artifactRegisterParams>,
messageStore?: MessageStore,
) {
const input: ArtifactCreateInput = {
type: params.type,
title: params.title,
description: params.description,
mimeType: params.mimeType,
uri: params.uri,
content: params.content,
authorId,
authorType: "agent",
taskId: params.taskId,
};
try {
const data = decodeArtifactDataBase64(params);
const input: ArtifactCreateInput = {
type: params.type,
title: params.title,
description: params.description,
mimeType: params.mimeType,
uri: params.uri,
content: params.content,
data,
authorId,
authorType: "agent",
taskId: params.taskId,
};
const artifact: Artifact = await store.registerArtifact(input);
notifyArtifactRegistered(messageStore, artifact, authorId);
return {
@@ -1473,6 +1478,37 @@ async function registerArtifactForAgent(
}
}
/**
* FNXC:ArtifactRegistry 2026-06-29-00:00:
* Agents need a portable way to create task-scoped image artifacts without reading arbitrary local files. `dataBase64` decodes inside the tool and then uses TaskStore's existing binary persistence path so registry rows continue to store only managed artifact URIs.
*/
function decodeArtifactDataBase64(params: Static<typeof artifactRegisterParams>): Buffer | undefined {
const encoded = params.dataBase64?.trim();
if (!encoded) {
return undefined;
}
if (params.uri || params.content) {
throw new Error("dataBase64 cannot be combined with uri or content; provide exactly one artifact payload source.");
}
if (params.type === "image" && !params.mimeType?.startsWith("image/")) {
throw new Error("image artifacts registered with dataBase64 require an image/* mimeType such as image/png.");
}
const normalized = encoded.replace(/\s+/g, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
throw new Error("dataBase64 must be valid base64-encoded artifact bytes.");
}
const data = Buffer.from(normalized, "base64");
if (data.length === 0 || data.toString("base64").replace(/=+$/, "") !== normalized.replace(/=+$/, "")) {
throw new Error("dataBase64 must decode to non-empty artifact bytes.");
}
return data;
}
function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifact: Artifact, authorId: string): void {
if (!messageStore) return;