FN-7767: fix Artifacts tab showing 0 count on default-scope dashboards
Fix useArtifacts fetching/subscribing only when a projectId is present, which left the Artifacts tab stuck at 0 on single-project dashboards where currentProject is unset at mount. - useArtifacts now builds a cache key and fetches/subscribes even without a projectId, scoping the cache under a __default__ key - SSE subscription omits the projectId query param when unset (default/unscoped /api/events) and only filters incoming events by projectId when one is set - Added/updated tests covering the default-scope fetch, cache, and SSE subscription paths - Added a changeset documenting the fix Files changed: .changeset/fn-7767-artifacts-default-scope.md | 7 ++++ .../app/hooks/__tests__/useArtifacts.test.ts | 42 +++++++++++++++++--- packages/dashboard/app/hooks/useArtifacts.ts | 37 ++++++------------ .../__tests__/artifacts-route-integration.test.ts | 45 ++++++++++++++++++++++ 4 files changed, 100 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-7767 Fusion-Task-Lineage: b4ea9b1f-2908-4f5b-bf75-d6fdc45f9340 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7767-artifacts-default-scope.md
Normal file
7
.changeset/fn-7767-artifacts-default-scope.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix the Artifacts tab count for default-scope dashboards.
|
||||
category: fix
|
||||
dev: useArtifacts now fetches and subscribes when no projectId is available, matching the default /api/artifacts scope.
|
||||
@@ -320,14 +320,46 @@ describe("useArtifacts", () => {
|
||||
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not subscribe or fetch when no projectId is available", async () => {
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-07-10-00:00:
|
||||
* The operator-visible "Artifacts tab always shows 0" repro was a single-project/default-scope dashboard mount where no currentProject id was threaded into useArtifacts. The server's real /api/artifacts route listed the agent-created image, but the hook short-circuited before fetch/SSE, so DocumentsView rendered a permanent 0 count.
|
||||
*/
|
||||
it("fetches and subscribes to the default artifact scope when no projectId is available", async () => {
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await loadInitialArtifacts();
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.artifacts).toEqual([]);
|
||||
expect(mockSubscribeSse).not.toHaveBeenCalled();
|
||||
expect(mockFetchArtifacts).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.artifacts).toEqual(mockArtifacts);
|
||||
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, undefined);
|
||||
expect(mockSubscribeSse).toHaveBeenCalledWith("/api/events", {
|
||||
events: expect.objectContaining({
|
||||
"artifact:registered": expect.any(Function),
|
||||
"message:received": expect.any(Function),
|
||||
"message:sent": expect.any(Function),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes default-scope artifacts when an unscoped artifact registration arrives", async () => {
|
||||
const updatedArtifacts = [...mockArtifacts, newTaskArtifact];
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
await loadInitialArtifacts();
|
||||
await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts));
|
||||
|
||||
mockFetchArtifacts.mockClear();
|
||||
mockFetchArtifacts.mockResolvedValue(updatedArtifacts);
|
||||
|
||||
await fireArtifactRegistration("artifact:registered", {
|
||||
id: newTaskArtifact.id,
|
||||
taskId: "FN-1",
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1));
|
||||
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, undefined);
|
||||
await waitFor(() => expect(result.current.artifacts).toEqual(updatedArtifacts));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,15 +33,13 @@ export function useArtifacts(options?: {
|
||||
}): UseArtifactsResult {
|
||||
const { projectId, type, authorId, taskId, searchQuery } = options ?? {};
|
||||
const filterKey = JSON.stringify({ type: type ?? null, authorId: authorId ?? null, taskId: taskId ?? null });
|
||||
const cacheKey = projectId ? `${SWR_CACHE_KEYS.ARTIFACTS_PREFIX}${projectId}:${filterKey}` : null;
|
||||
const projectScopeKey = projectId ?? "__default__";
|
||||
const cacheKey = `${SWR_CACHE_KEYS.ARTIFACTS_PREFIX}${projectScopeKey}:${filterKey}`;
|
||||
const [artifacts, setArtifacts] = useState<ArtifactWithTask[]>(() => {
|
||||
if (!cacheKey) {
|
||||
return [];
|
||||
}
|
||||
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
||||
return Array.isArray(cached) ? cached : [];
|
||||
});
|
||||
const [loading, setLoading] = useState(() => Boolean(projectId) && artifacts.length === 0);
|
||||
const [loading, setLoading] = useState(() => artifacts.length === 0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const initialLoadCompleteRef = useRef(artifacts.length > 0);
|
||||
@@ -50,13 +48,6 @@ export function useArtifacts(options?: {
|
||||
const sseRefreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setArtifacts([]);
|
||||
setLoading(false);
|
||||
initialLoadCompleteRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
@@ -105,13 +96,6 @@ export function useArtifacts(options?: {
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey) {
|
||||
initialLoadCompleteRef.current = false;
|
||||
setArtifacts([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
||||
if (Array.isArray(cached)) {
|
||||
setArtifacts(cached);
|
||||
@@ -141,13 +125,12 @@ export function useArtifacts(options?: {
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey || !projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-06-27-00:00:
|
||||
* Already-open task and project artifact lists must live-refresh from TaskStore's authoritative artifact:registered SSE event and also accept the best-effort agent/chat message notifications as an additional signal. Task-scoped hooks filter by artifact/task metadata while project-scoped hooks rely on the projectId refetch and optional payload projectId guard, preserving SWR cached rendering, search filters, and scoped fetch behavior without showing a loading flash.
|
||||
*
|
||||
* FNXC:ArtifactRegistry 2026-07-10-00:00:
|
||||
* Single-project dashboards do not always have a currentProject id when the Documents view mounts. The Artifacts tab must still fetch the server's default project scope and subscribe to unscoped SSE; otherwise the hook returns [] forever and the tab count stays 0 even though GET /api/artifacts and agent-created image media are valid.
|
||||
*/
|
||||
const handleArtifactRegistration = (event: MessageEvent, source: "artifact" | "message") => {
|
||||
try {
|
||||
@@ -163,7 +146,7 @@ export function useArtifacts(options?: {
|
||||
const artifactId = source === "artifact" ? payload.id : payload.metadata?.artifactId;
|
||||
const artifactTaskId = source === "artifact" ? payload.taskId : payload.metadata?.taskId;
|
||||
if (!artifactId) return;
|
||||
if (payload.projectId && payload.projectId !== projectId) return;
|
||||
if (projectId && payload.projectId && payload.projectId !== projectId) return;
|
||||
if (taskId && artifactTaskId !== taskId) return;
|
||||
|
||||
if (sseRefreshDebounceRef.current) {
|
||||
@@ -182,7 +165,9 @@ export function useArtifacts(options?: {
|
||||
const handleArtifactMessage = (event: MessageEvent) => handleArtifactRegistration(event, "message");
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("projectId", projectId);
|
||||
if (projectId) {
|
||||
params.set("projectId", projectId);
|
||||
}
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
const unsubscribe = subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
@@ -199,7 +184,7 @@ export function useArtifacts(options?: {
|
||||
sseRefreshDebounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [cacheKey, projectId, taskId]);
|
||||
}, [projectId, taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
|
||||
@@ -7,6 +7,7 @@ import http from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, type ArtifactType, type ArtifactWithTask } from "@fusion/core";
|
||||
import { createArtifactRegisterTool } from "@fusion/engine";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
@@ -130,6 +131,50 @@ describe("artifacts route integration", () => {
|
||||
expect(res.body).toEqual(imageBytes);
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-07-10-00:00:
|
||||
* FN-7767 pins the real-server/default-scope invariant the in-memory FN-7693/FN-7764 tests missed: an image created through the agent fn_artifact_register tool must be visible through the dashboard route with no projectId query (single-project/default server scope) and stream as image/png.
|
||||
*/
|
||||
it("an agent-tool image artifact is listed and streamed through the default server scope", async () => {
|
||||
const task = await store.createTask({
|
||||
title: "Agent screenshot",
|
||||
description: "Artifact should surface in the default Artifacts tab scope",
|
||||
});
|
||||
const registerTool = createArtifactRegisterTool(store, "agent-fn-7767");
|
||||
|
||||
const registerResult = await registerTool.execute("call-register-fn-7767-image", {
|
||||
type: "image",
|
||||
title: "Agent-created screenshot",
|
||||
description: "Default-scope image artifact",
|
||||
mimeType: "image/png",
|
||||
dataBase64: PNG_IMAGE_BYTES.toString("base64"),
|
||||
taskId: task.id,
|
||||
});
|
||||
const artifactId = (registerResult.details as { artifactId?: string }).artifactId;
|
||||
expect(artifactId).toBeTruthy();
|
||||
|
||||
const listRes = await REQUEST(app, "GET", "/api/artifacts");
|
||||
|
||||
expect(listRes.status).toBe(200);
|
||||
const listed = (listRes.body as ArtifactWithTask[]).find((artifact) => artifact.id === artifactId);
|
||||
expect(listed).toMatchObject({
|
||||
id: artifactId,
|
||||
type: "image",
|
||||
title: "Agent-created screenshot",
|
||||
mimeType: "image/png",
|
||||
authorId: "agent-fn-7767",
|
||||
authorType: "agent",
|
||||
taskId: task.id,
|
||||
taskTitle: "Agent screenshot",
|
||||
});
|
||||
expect(listRes.body).toHaveLength(1);
|
||||
|
||||
const mediaRes = await requestRawBuffer(app, `/api/artifacts/${artifactId}/media`);
|
||||
expect(mediaRes.status).toBe(200);
|
||||
expect(mediaRes.headers["content-type"]).toBe("image/png");
|
||||
expect(mediaRes.body).toEqual(PNG_IMAGE_BYTES);
|
||||
});
|
||||
|
||||
it("a global image artifact still streams from the managed global artifacts directory", async () => {
|
||||
const imageBytes = PNG_IMAGE_BYTES;
|
||||
const artifact = await store.registerArtifact({
|
||||
|
||||
Reference in New Issue
Block a user