fix(FN-2098): align no-task heartbeat guidance and stabilize flaky tests
- Update HEARTBEAT_NO_TASK_SYSTEM_PROMPT copy to emphasize inbox, memory, delegation, and heartbeat_done usage - Expand heartbeat monitor tests to assert no-task prompt/tool alignment and preserve task-scoped prompt behavior - Harden first-run and App view tests by using a safe cwd fallback and more robust async UI waits - Add best-effort dashboard performance reporting hooks in App and useProjects via a new reportDashboardPerf API helper
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tempWorkspace } from "@fusion/test-utils";
|
||||
import { FirstRunExperience, createFirstRunExperience } from "../first-run.js";
|
||||
import { CentralCore } from "../central-core.js";
|
||||
@@ -12,6 +13,17 @@ function createFakeKbProject(dir: string): void {
|
||||
writeFileSync(join(dir, ".fusion", "fusion.db"), "");
|
||||
}
|
||||
|
||||
const TEST_FILE_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function getSafeCwd(): string {
|
||||
try {
|
||||
return process.cwd();
|
||||
} catch {
|
||||
process.chdir(TEST_FILE_DIR);
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
|
||||
describe("FirstRunExperience", () => {
|
||||
let tempDir: string;
|
||||
let centralCore: CentralCore;
|
||||
@@ -27,7 +39,7 @@ describe("FirstRunExperience", () => {
|
||||
const globalSettingsStore = new GlobalSettingsStore(tempDir);
|
||||
await globalSettingsStore.init();
|
||||
firstRun = new FirstRunExperience(centralCore, globalSettingsStore);
|
||||
originalCwd = process.cwd();
|
||||
originalCwd = getSafeCwd();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { Header, useViewportMode } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
@@ -49,7 +49,7 @@ import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
import { fetchAiSession, fetchUnreadCount } from "./api";
|
||||
import { fetchAiSession, fetchUnreadCount, reportDashboardPerf } from "./api";
|
||||
|
||||
function AppInner() {
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
@@ -133,6 +133,9 @@ function AppInner() {
|
||||
);
|
||||
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
const mountTimeRef = useRef(performance.now());
|
||||
const projectsReadyLoggedRef = useRef(false);
|
||||
const projectReadyLoggedRef = useRef(false);
|
||||
|
||||
const loadingStage = useMemo<DashboardLoaderStage>(() => {
|
||||
if (projectsLoading) return "projects";
|
||||
@@ -140,6 +143,21 @@ function AppInner() {
|
||||
return "tasks";
|
||||
}, [projectsLoading, currentProjectLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectsLoading && !projectsReadyLoggedRef.current) {
|
||||
projectsReadyLoggedRef.current = true;
|
||||
const msg = `projects loaded at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
|
||||
console.log(`[App] ${msg}`);
|
||||
reportDashboardPerf("[App]", msg);
|
||||
}
|
||||
if (!currentProjectLoading && !projectReadyLoggedRef.current) {
|
||||
projectReadyLoggedRef.current = true;
|
||||
const msg = `current-project resolved at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
|
||||
console.log(`[App] ${msg}`);
|
||||
reportDashboardPerf("[App]", msg);
|
||||
}
|
||||
}, [projectsLoading, currentProjectLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadComplete) {
|
||||
return;
|
||||
@@ -149,7 +167,11 @@ function AppInner() {
|
||||
return;
|
||||
}
|
||||
|
||||
const settleStart = performance.now();
|
||||
const settleTimer = window.setTimeout(() => {
|
||||
const msg = `dashboard ready at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount (settle delay=${Math.round(performance.now() - settleStart)}ms)`;
|
||||
console.log(`[App] ${msg}`);
|
||||
reportDashboardPerf("[App]", msg);
|
||||
setInitialLoadComplete(true);
|
||||
}, 200);
|
||||
|
||||
|
||||
@@ -3454,6 +3454,19 @@ export function fetchProjectsAcrossNodes(): Promise<ProjectInfoWithSource[]> {
|
||||
return api<ProjectInfoWithSource[]>("/projects/across-nodes");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a client-side perf measurement to the shared dashboard-perf log on disk.
|
||||
* Used when browser devtools aren't available (e.g. mobile). Best-effort.
|
||||
*/
|
||||
export function reportDashboardPerf(source: string, message: string): void {
|
||||
void api("/_perf/dashboard-load", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source, message }),
|
||||
}).catch(() => {
|
||||
// best-effort only
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch all registered nodes */
|
||||
export function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return api<NodeInfo[]>("/nodes");
|
||||
|
||||
@@ -1205,18 +1205,15 @@ describe("App view switching", () => {
|
||||
it("renders AgentsView when agents view is selected", async () => {
|
||||
render(<App />);
|
||||
|
||||
// Wait for the header to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Agents view")).toBeTruthy();
|
||||
});
|
||||
const agentsViewButton = await screen.findByTitle("Agents view", {}, { timeout: 5000 });
|
||||
|
||||
// Click to switch to agents view
|
||||
fireEvent.click(screen.getByTitle("Agents view"));
|
||||
fireEvent.click(agentsViewButton);
|
||||
|
||||
// Agents view should be rendered (it has a agents-view container)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".agents-view")).toBeTruthy();
|
||||
});
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Should NOT show board or list view
|
||||
expect(document.querySelector(".board")).toBeNull();
|
||||
@@ -1228,15 +1225,13 @@ describe("App view switching", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Agents view")).toBeTruthy();
|
||||
});
|
||||
const agentsViewButton = await screen.findByTitle("Agents view", {}, { timeout: 5000 });
|
||||
|
||||
fireEvent.click(screen.getByTitle("Agents view"));
|
||||
fireEvent.click(agentsViewButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(taskViewStorageKey())).toBe("agents");
|
||||
});
|
||||
}, { timeout: 5000 });
|
||||
});
|
||||
|
||||
it("initializes agents view from localStorage if saved", async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ProjectInfo } from "../api";
|
||||
import {
|
||||
fetchProjectsAcrossNodes,
|
||||
registerProject,
|
||||
reportDashboardPerf,
|
||||
unregisterProject,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
@@ -59,13 +60,22 @@ export function useProjects(): UseProjectsResult {
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const data = await fetchProjectsAcrossNodes();
|
||||
const elapsed = Math.round(performance.now() - t0);
|
||||
const msg = `initial fetchProjectsAcrossNodes took ${elapsed}ms (${data.length} projects)`;
|
||||
console.log(`[useProjects] ${msg}`);
|
||||
reportDashboardPerf("[useProjects]", msg);
|
||||
if (!cancelled) {
|
||||
setProjects(data);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - t0);
|
||||
const msg = `initial fetch failed after ${elapsed}ms: ${err instanceof Error ? err.message : String(err)}`;
|
||||
console.warn(`[useProjects] ${msg}`);
|
||||
reportDashboardPerf("[useProjects]", msg);
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
||||
}
|
||||
|
||||
@@ -1361,6 +1361,33 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(toolNames).not.toContain("task_document_read");
|
||||
});
|
||||
|
||||
it("no-task run system prompt uses HEARTBEAT_NO_TASK_SYSTEM_PROMPT and does not reference task-scoped tools", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
const callArgs = mockedCreateKbAgent.mock.calls[0]![0]!;
|
||||
const systemPrompt = callArgs.systemPrompt;
|
||||
|
||||
expect(systemPrompt).toContain(HEARTBEAT_NO_TASK_SYSTEM_PROMPT);
|
||||
expect(systemPrompt).not.toContain("task_log");
|
||||
expect(systemPrompt).not.toContain("task_document_write");
|
||||
expect(systemPrompt).not.toContain("task_document_read");
|
||||
expect(systemPrompt).toContain("task_create");
|
||||
expect(systemPrompt).toContain("list_agents");
|
||||
expect(systemPrompt).toContain("delegate_task");
|
||||
expect(systemPrompt).toContain("read_messages");
|
||||
expect(systemPrompt).toContain("send_message");
|
||||
expect(systemPrompt).toContain("memory_search");
|
||||
expect(systemPrompt).toContain("memory_append");
|
||||
expect(systemPrompt).toContain("heartbeat_done");
|
||||
});
|
||||
|
||||
it("identity agent without task receives no-task execution prompt mentioning 'no assigned task'", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
||||
const mockSession = createMockAgentSession();
|
||||
@@ -1373,6 +1400,7 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
const callArgs = mockedCreateKbAgent.mock.calls[0]![0]!;
|
||||
const systemPrompt = callArgs.systemPrompt;
|
||||
expect(systemPrompt).toContain(HEARTBEAT_NO_TASK_SYSTEM_PROMPT);
|
||||
expect(systemPrompt).not.toContain("task_log");
|
||||
expect(systemPrompt).not.toContain("task_document_write");
|
||||
expect(systemPrompt).not.toContain("task_document_read");
|
||||
@@ -1398,7 +1426,7 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(executionPrompt).not.toContain("Task description:");
|
||||
});
|
||||
|
||||
it("task-scoped heartbeat run receives full system prompt with task_log and task Documents", async () => {
|
||||
it("task-scoped run system prompt uses original HEARTBEAT_SYSTEM_PROMPT", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
@@ -1411,6 +1439,7 @@ describe("HeartbeatMonitor", () => {
|
||||
const callArgs = mockedCreateKbAgent.mock.calls[0]![0]!;
|
||||
const systemPrompt = callArgs.systemPrompt;
|
||||
|
||||
expect(systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
|
||||
expect(systemPrompt).toContain("task_log");
|
||||
expect(systemPrompt).toContain("task_document_write");
|
||||
expect(systemPrompt).toContain("Task Documents:");
|
||||
@@ -2220,11 +2249,19 @@ describe("HeartbeatMonitor", () => {
|
||||
});
|
||||
|
||||
describe("execution", () => {
|
||||
it("no-task heartbeat system prompt does not reference task-scoped tools", () => {
|
||||
it("HEARTBEAT_NO_TASK_SYSTEM_PROMPT does not mention task_log or task_document tools", () => {
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("task_log");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("task_document_write");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("task_document_read");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("task_document");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("task_create");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("list_agents");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("delegate_task");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("read_messages");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("send_message");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("memory_search");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("memory_append");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("heartbeat_done");
|
||||
});
|
||||
|
||||
it("creates session with enriched system prompt and expected tools", async () => {
|
||||
|
||||
@@ -169,12 +169,15 @@ When sending messages:
|
||||
export const HEARTBEAT_NO_TASK_SYSTEM_PROMPT = `You are a heartbeat agent running in a short execution window.
|
||||
|
||||
Your job:
|
||||
1. Do ONE useful action: analyze, review, create follow-up tasks, or log findings.
|
||||
2. Use task_create to spawn follow-up work.
|
||||
3. Call heartbeat_done when finished with an optional summary of what was accomplished.
|
||||
1. Review your context — check messages, memory, and project state.
|
||||
2. Do ONE useful action: analyze, create follow-up tasks, or update memory.
|
||||
3. Use task_create to spawn follow-up work.
|
||||
4. Use list_agents and delegate_task to assign work to other agents.
|
||||
5. Call heartbeat_done when finished with an optional summary of what was accomplished.
|
||||
|
||||
Keep work lightweight — this is a single-pass check, not a full implementation run.
|
||||
You have readonly file access plus task_create, list_agents, delegate_task, messaging, and memory tools (memory_search, memory_get, memory_append).
|
||||
You have readonly file access plus task_create, list_agents, delegate_task, messaging, memory, and heartbeat_done tools.
|
||||
Use read_messages and send_message for inbox processing, and memory_search, memory_get, and memory_append for memory workflows.
|
||||
|
||||
## Memory Boundaries
|
||||
|
||||
@@ -189,9 +192,9 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
|
||||
1. Use read_messages to check your inbox for unread messages.
|
||||
2. Review each message and determine the appropriate action:
|
||||
- If the message requires a response, use send_message to reply.
|
||||
- If the message is informational, acknowledge it with a brief response or note it in memory.
|
||||
- If the message is informational, acknowledge it and respond via send_message if appropriate.
|
||||
- If the message requests work, create a follow-up task with task_create or handle it directly.
|
||||
3. After processing messages, continue with your normal heartbeat duties.
|
||||
3. After processing messages, continue with your ambient work.
|
||||
|
||||
When sending messages:
|
||||
- Be concise and clear about what you need or what you've done.
|
||||
|
||||
Reference in New Issue
Block a user