diff --git a/.changeset/fn-7143-image-artifact-task-details.md b/.changeset/fn-7143-image-artifact-task-details.md new file mode 100644 index 0000000000..fd2c52cf51 --- /dev/null +++ b/.changeset/fn-7143-image-artifact-task-details.md @@ -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. diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index c38556d4ed..5c53e0e14c 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -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) | diff --git a/packages/dashboard/app/components/TaskDocumentsTab.tsx b/packages/dashboard/app/components/TaskDocumentsTab.tsx index c602bdaeb2..bc6912b31c 100644 --- a/packages/dashboard/app/components/TaskDocumentsTab.tsx +++ b/packages/dashboard/app/components/TaskDocumentsTab.tsx @@ -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 (
-
- -
+ {artifact.type === "image" ? ( + + ) : ( +
+ +
+ )}
{typeLabel} @@ -110,6 +123,13 @@ export function TaskDocumentsTab({ const [deletingKey, setDeletingKey] = useState(null); const [confirmDelete, setConfirmDelete] = useState(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(null); + const lightboxCloseRef = useRef(null); + const lightboxReturnFocusRef = useRef(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) => { + if (event.target === event.currentTarget) { + handleCloseLightbox(); + } + }, [handleCloseLightbox]); + if (loading || artifactsLoading) { return (
@@ -299,7 +359,7 @@ export function TaskDocumentsTab({
{artifacts.map((artifact) => ( - + ))}
@@ -549,6 +609,32 @@ export function TaskDocumentsTab({ )} + + {lightboxArtifact && ( +
+
event.stopPropagation()}> +
+

{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}

+ +
+
+ {lightboxArtifact.title +
+
+
+ )}
); } diff --git a/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx index 651aa919d2..1e6285d123 100644 --- a/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx @@ -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(); + + 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(); + + 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: [], diff --git a/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts b/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts index bb124d16aa..c4a9aed7c4 100644 --- a/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts +++ b/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts @@ -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", diff --git a/packages/engine/src/__tests__/agent-artifact-tools.test.ts b/packages/engine/src/__tests__/agent-artifact-tools.test.ts index 92ac587f62..cadffda302 100644 --- a/packages/engine/src/__tests__/agent-artifact-tools.test.ts +++ b/packages/engine/src/__tests__/agent-artifact-tools.test.ts @@ -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" })); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 95ff91030e..41767db247 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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) => 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) => 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, 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): 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;