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:
7
.changeset/fn-7143-image-artifact-task-details.md
Normal file
7
.changeset/fn-7143-image-artifact-task-details.md
Normal 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.
|
||||||
@@ -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_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_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_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_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) |
|
| `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) |
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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 "./DocumentsView.css";
|
||||||
import "./TaskDocumentsTab.css";
|
import "./TaskDocumentsTab.css";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
@@ -56,9 +56,10 @@ function formatFileSize(bytes: number): string {
|
|||||||
interface TaskArtifactCardProps {
|
interface TaskArtifactCardProps {
|
||||||
artifact: ArtifactWithTask;
|
artifact: ArtifactWithTask;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
onExpandImage: (artifact: ArtifactWithTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskArtifactCard({ artifact, projectId }: TaskArtifactCardProps) {
|
function TaskArtifactCard({ artifact, projectId, onExpandImage }: TaskArtifactCardProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
|
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
|
||||||
const typeLabel = getArtifactTypeLabel(t, artifact.type);
|
const typeLabel = getArtifactTypeLabel(t, artifact.type);
|
||||||
@@ -67,9 +68,21 @@ function TaskArtifactCard({ artifact, projectId }: TaskArtifactCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="document-card documents-artifact-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
<article className="document-card documents-artifact-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
||||||
<div className="documents-artifact-preview">
|
{artifact.type === "image" ? (
|
||||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
|
<button
|
||||||
</div>
|
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-body">
|
||||||
<div className="documents-artifact-header">
|
<div className="documents-artifact-header">
|
||||||
<span className="documents-artifact-type-badge">{typeLabel}</span>
|
<span className="documents-artifact-type-badge">{typeLabel}</span>
|
||||||
@@ -110,6 +123,13 @@ export function TaskDocumentsTab({
|
|||||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
const [renderMarkdown, setRenderMarkdown] = useState(false);
|
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 { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId });
|
||||||
|
|
||||||
const loadDocuments = useCallback(async () => {
|
const loadDocuments = useCallback(async () => {
|
||||||
@@ -266,6 +286,46 @@ export function TaskDocumentsTab({
|
|||||||
setEditContent("");
|
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) {
|
if (loading || artifactsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
@@ -299,7 +359,7 @@ export function TaskDocumentsTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className="documents-artifact-gallery documents-artifact-gallery--mobile task-artifacts-gallery">
|
<div className="documents-artifact-gallery documents-artifact-gallery--mobile task-artifacts-gallery">
|
||||||
{artifacts.map((artifact) => (
|
{artifacts.map((artifact) => (
|
||||||
<TaskArtifactCard key={artifact.id} artifact={artifact} projectId={projectId} />
|
<TaskArtifactCard key={artifact.id} artifact={artifact} projectId={projectId} onExpandImage={handleExpandArtifactImage} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -549,6 +609,32 @@ export function TaskDocumentsTab({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ describe("TaskDocumentsTab", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
|
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("Video artifact: Video artifact").tagName).toBe("VIDEO");
|
||||||
expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO");
|
expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO");
|
||||||
expect(screen.getByTestId("artifact-document-preview")).toHaveTextContent("Inline document preview");
|
expect(screen.getByTestId("artifact-document-preview")).toHaveTextContent("Inline document preview");
|
||||||
@@ -202,6 +204,54 @@ describe("TaskDocumentsTab", () => {
|
|||||||
expect(mockArtifactMediaUrl).toHaveBeenCalledWith("artifact-image", "project-1");
|
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 () => {
|
it("surfaces artifact fetch errors", async () => {
|
||||||
mockUseArtifacts.mockReturnValue({
|
mockUseArtifacts.mockReturnValue({
|
||||||
artifacts: [],
|
artifacts: [],
|
||||||
|
|||||||
@@ -84,6 +84,44 @@ describe("artifacts route integration", () => {
|
|||||||
expect(Buffer.from(res.body as string, "utf8")).toEqual(imageBytes);
|
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 () => {
|
it("a task with no artifacts lists as empty", async () => {
|
||||||
const task = await store.createTask({
|
const task = await store.createTask({
|
||||||
title: "Render screenshot",
|
title: "Render screenshot",
|
||||||
|
|||||||
@@ -89,6 +89,72 @@ describe("artifact register tool", () => {
|
|||||||
vi.clearAllMocks();
|
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 () => {
|
it("calls store.registerArtifact with mapped agent author input", async () => {
|
||||||
const { store, registerArtifact } = createMockStore();
|
const { store, registerArtifact } = createMockStore();
|
||||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-register" }));
|
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 () => {
|
it("registers with explicit task_id and fixed dashboard-chat author", async () => {
|
||||||
const { store, registerArtifact } = createMockStore();
|
const { store, registerArtifact } = createMockStore();
|
||||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-chat", authorId: "dashboard-chat", taskId: "FN-3030" }));
|
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-chat", authorId: "dashboard-chat", taskId: "FN-3030" }));
|
||||||
|
|||||||
@@ -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." })),
|
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." })),
|
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." })),
|
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')." })),
|
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." })),
|
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." })),
|
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." })),
|
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')." }),
|
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",
|
label: "Register Artifact",
|
||||||
description:
|
description:
|
||||||
"Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it. " +
|
"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,
|
parameters: artifactRegisterParams,
|
||||||
execute: async (_id: string, params: Static<typeof artifactRegisterParams>) => registerArtifactForAgent(store, authorId, params, messageStore),
|
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",
|
name: "fn_artifact_register",
|
||||||
label: "Register Artifact",
|
label: "Register Artifact",
|
||||||
description:
|
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,
|
parameters: chatArtifactRegisterParams,
|
||||||
execute: async (_id: string, params: Static<typeof chatArtifactRegisterParams>) => registerArtifactForAgent(
|
execute: async (_id: string, params: Static<typeof chatArtifactRegisterParams>) => registerArtifactForAgent(
|
||||||
store,
|
store,
|
||||||
@@ -1409,6 +1411,7 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
|
|||||||
mimeType: params.mimeType,
|
mimeType: params.mimeType,
|
||||||
uri: params.uri,
|
uri: params.uri,
|
||||||
content: params.content,
|
content: params.content,
|
||||||
|
dataBase64: params.dataBase64,
|
||||||
taskId: params.task_id,
|
taskId: params.task_id,
|
||||||
},
|
},
|
||||||
messageStore,
|
messageStore,
|
||||||
@@ -1439,19 +1442,21 @@ async function registerArtifactForAgent(
|
|||||||
params: Static<typeof artifactRegisterParams>,
|
params: Static<typeof artifactRegisterParams>,
|
||||||
messageStore?: MessageStore,
|
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 {
|
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);
|
const artifact: Artifact = await store.registerArtifact(input);
|
||||||
notifyArtifactRegistered(messageStore, artifact, authorId);
|
notifyArtifactRegistered(messageStore, artifact, authorId);
|
||||||
return {
|
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 {
|
function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifact: Artifact, authorId: string): void {
|
||||||
if (!messageStore) return;
|
if (!messageStore) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user