Agent run fixes

This commit is contained in:
gsxdsm
2026-05-09 17:05:43 -07:00
parent e07a9d4e64
commit e0d96712c3
5 changed files with 179 additions and 61 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Move agent Run Now control into the agent detail header next to lifecycle buttons.

View File

@@ -66,7 +66,7 @@ describe("Agent runs UI — static analysis", () => {
}); });
}); });
describe("AgentDetailView RunsTab", () => { describe("AgentDetailView run controls", () => {
it("loads runs via fetchAgentRuns API", () => { it("loads runs via fetchAgentRuns API", () => {
expect(agentDetailViewContent).toMatch(/fetchAgentRuns/); expect(agentDetailViewContent).toMatch(/fetchAgentRuns/);
}); });
@@ -79,8 +79,8 @@ describe("Agent runs UI — static analysis", () => {
expect(agentDetailViewContent).toMatch(/import.*startAgentRun.*from.*api/); expect(agentDetailViewContent).toMatch(/import.*startAgentRun.*from.*api/);
}); });
it("has Run Now button in runs tab", () => { it("has Run Now button in header controls", () => {
expect(agentDetailViewContent).toMatch(/Run Now/); expect(agentDetailViewContent).toMatch(/agent-detail-controls[\s\S]*Run Now/);
expect(agentDetailViewContent).toMatch(/handleRunHeartbeat/); expect(agentDetailViewContent).toMatch(/handleRunHeartbeat/);
}); });

View File

@@ -140,6 +140,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? "dashboard"); const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? "dashboard");
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const [isStartingRun, setIsStartingRun] = useState(false);
const [runNowRefreshToken, setRunNowRefreshToken] = useState(0);
const [latestRun, setLatestRun] = useState<AgentHeartbeatRun | null>(null); const [latestRun, setLatestRun] = useState<AgentHeartbeatRun | null>(null);
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null); const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
const [isLoadingMailbox, setIsLoadingMailbox] = useState(false); const [isLoadingMailbox, setIsLoadingMailbox] = useState(false);
@@ -458,6 +460,20 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
} }
}; };
const handleRunHeartbeat = async () => {
if (isStartingRun) return;
setIsStartingRun(true);
try {
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
addToast(`Heartbeat run started for ${agent?.name ?? agentId}`, "success");
setRunNowRefreshToken((prev) => prev + 1);
} catch (err) {
addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error");
} finally {
setIsStartingRun(false);
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!agent) return; if (!agent) return;
const shouldDelete = await confirm({ const shouldDelete = await confirm({
@@ -592,6 +608,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<Play size={14} /> <Play size={14} />
Start Start
</button> </button>
<button
className="btn btn-task-create btn--compact"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agent.name}`}
disabled={isStartingRun || isTransitioning}
>
<Activity size={14} />
Run Now
</button>
<button className="btn btn--danger btn--compact" onClick={handleDelete}> <button className="btn btn--danger btn--compact" onClick={handleDelete}>
<Trash2 size={14} /> <Trash2 size={14} />
Delete Delete
@@ -608,6 +633,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<Square size={14} /> <Square size={14} />
Stop Stop
</button> </button>
<button
className="btn btn-task-create btn--compact"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agent.name}`}
disabled={isStartingRun || isTransitioning}
>
<Activity size={14} />
Run Now
</button>
</> </>
)} )}
{agent.state === "paused" && ( {agent.state === "paused" && (
@@ -717,6 +751,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
agentName={agent.name} agentName={agent.name}
initialRunId={initialRunId} initialRunId={initialRunId}
preferActiveRun={preferActiveRun} preferActiveRun={preferActiveRun}
runNowRefreshToken={runNowRefreshToken}
/> />
)} )}
@@ -1443,6 +1478,7 @@ function RunsTab({
agentName, agentName,
initialRunId, initialRunId,
preferActiveRun, preferActiveRun,
runNowRefreshToken,
}: { }: {
addToast: (msg: string, type?: "success" | "error") => void; addToast: (msg: string, type?: "success" | "error") => void;
agentId: string; agentId: string;
@@ -1451,6 +1487,7 @@ function RunsTab({
agentName?: string; agentName?: string;
initialRunId?: string | null; initialRunId?: string | null;
preferActiveRun?: boolean; preferActiveRun?: boolean;
runNowRefreshToken: number;
}) { }) {
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]); const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const { confirm } = useConfirm(); const { confirm } = useConfirm();
@@ -1461,6 +1498,7 @@ function RunsTab({
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null); const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false); const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const hasAutoExpandedInitialRunRef = useRef(false); const hasAutoExpandedInitialRunRef = useRef(false);
const didMountRunNowRefreshRef = useRef(false);
// Load runs on mount // Load runs on mount
const loadRuns = useCallback(async () => { const loadRuns = useCallback(async () => {
@@ -1478,6 +1516,15 @@ function RunsTab({
void loadRuns(); void loadRuns();
}, [loadRuns]); }, [loadRuns]);
useEffect(() => {
if (!didMountRunNowRefreshRef.current) {
didMountRunNowRefreshRef.current = true;
return;
}
setIsLoadingRuns(true);
void loadRuns();
}, [loadRuns, runNowRefreshToken]);
// Poll for active runs // Poll for active runs
const hasActiveRun = runs.some(r => r.status === "active"); const hasActiveRun = runs.some(r => r.status === "active");
const selectedRunStatus = selectedRunId const selectedRunStatus = selectedRunId
@@ -1565,16 +1612,6 @@ function RunsTab({
} }
}, [initialRunId, preferActiveRun, runs, isLoadingRuns, handleRunClick]); }, [initialRunId, preferActiveRun, runs, isLoadingRuns, handleRunClick]);
const handleRunHeartbeat = async () => {
try {
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
addToast(`Heartbeat run started for ${agentName ?? agentId}`, "success");
setIsLoadingRuns(true);
void loadRuns();
} catch (err) {
addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error");
}
};
const handleStopRun = async () => { const handleStopRun = async () => {
const shouldStop = await confirm({ const shouldStop = await confirm({
@@ -1596,7 +1633,6 @@ function RunsTab({
} }
}; };
const canRunHeartbeat = agentState === "active" || agentState === "idle";
if (isLoadingRuns && runs.length === 0) { if (isLoadingRuns && runs.length === 0) {
return ( return (
@@ -1612,17 +1648,6 @@ function RunsTab({
if (runs.length === 0) { if (runs.length === 0) {
return ( return (
<div className="runs-tab"> <div className="runs-tab">
{canRunHeartbeat && (
<div className="runs-toolbar">
<button
className="btn btn--sm btn-task-create"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agentName ?? agentId}`}
>
<Activity size={14} /> Run Now
</button>
</div>
)}
<div className="runs-empty"> <div className="runs-empty">
<Activity size={48} opacity={0.3} /> <Activity size={48} opacity={0.3} />
<p>No runs yet</p> <p>No runs yet</p>
@@ -1854,7 +1879,6 @@ function RunsTab({
return ( return (
<div className="runs-tab"> <div className="runs-tab">
{canRunHeartbeat && (
<div className="runs-toolbar runs-toolbar--between"> <div className="runs-toolbar runs-toolbar--between">
<span className="runs-toolbar-meta"> <span className="runs-toolbar-meta">
{runs.length} run{runs.length !== 1 ? "s" : ""} {runs.length} run{runs.length !== 1 ? "s" : ""}
@@ -1870,16 +1894,8 @@ function RunsTab({
<Square size={14} /> Stop Run <Square size={14} /> Stop Run
</button> </button>
)} )}
<button
className="btn btn--sm btn-task-create"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agentName ?? agentId}`}
>
<Activity size={14} /> Run Now
</button>
</div> </div>
</div> </div>
)}
{activeRuns.map((run, i) => renderRunCard(run, i, true))} {activeRuns.map((run, i) => renderRunCard(run, i, true))}
{completedRuns.map((run, i) => renderRunCard(run, activeRuns.length + i, false))} {completedRuns.map((run, i) => renderRunCard(run, activeRuns.length + i, false))}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture"; import { loadAllAppCss } from "../../test/cssFixture";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; import { render, screen, waitFor, fireEvent, act, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import { AgentDetailView } from "../AgentDetailView"; import { AgentDetailView } from "../AgentDetailView";
@@ -161,7 +161,7 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }), useConfirm: () => ({ confirm: mockConfirm }),
})); }));
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, fetchAgentMailbox, markMessageRead, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api"; import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, fetchAgentMailbox, markMessageRead, startAgentRun, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
import { subscribeSse } from "../../sse-bus"; import { subscribeSse } from "../../sse-bus";
const mockFetchAgent = vi.mocked(fetchAgent); const mockFetchAgent = vi.mocked(fetchAgent);
@@ -192,6 +192,7 @@ const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta); const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
const mockFetchAgentMailbox = vi.mocked(fetchAgentMailbox); const mockFetchAgentMailbox = vi.mocked(fetchAgentMailbox);
const mockMarkMessageRead = vi.mocked(markMessageRead); const mockMarkMessageRead = vi.mocked(markMessageRead);
const mockStartAgentRun = vi.mocked(startAgentRun);
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure); const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings); const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
const mockFetchCompanies = vi.mocked(fetchCompanies); const mockFetchCompanies = vi.mocked(fetchCompanies);
@@ -242,6 +243,7 @@ describe("AgentDetailView", () => {
mockSubscribeSse.mockReturnValue(vi.fn()); mockSubscribeSse.mockReturnValue(vi.fn());
const mockAgent = createMockAgent(); const mockAgent = createMockAgent();
mockFetchAgent.mockResolvedValue(mockAgent); mockFetchAgent.mockResolvedValue(mockAgent);
mockStartAgentRun.mockResolvedValue({ id: "run-003" } as any);
mockFetchAgents.mockResolvedValue([ mockFetchAgents.mockResolvedValue([
{ id: "agent-001", name: "Test Agent", role: "executor", state: "active", metadata: {} }, { id: "agent-001", name: "Test Agent", role: "executor", state: "active", metadata: {} },
{ id: "agent-002", name: "Manager Agent", role: "reviewer", state: "active", metadata: {} }, { id: "agent-002", name: "Manager Agent", role: "reviewer", state: "active", metadata: {} },
@@ -1679,6 +1681,102 @@ describe("AgentDetailView", () => {
openSpy.mockRestore(); openSpy.mockRestore();
}); });
it("renders Run Now in header for active and idle states only", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Run now for Test Agent" })).toBeInTheDocument();
});
cleanup();
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ state: "idle", taskId: undefined }));
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Run now for Test Agent" })).toBeInTheDocument();
});
cleanup();
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ state: "running", taskId: undefined }));
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Run now for Test Agent" })).not.toBeInTheDocument();
});
});
it("starts run from header and refreshes runs without runs-tab Run Now", async () => {
const addToast = vi.fn();
const user = userEvent.setup();
mockFetchAgentRuns.mockResolvedValue([
{
id: "run-001",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun,
]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Run now for Test Agent" })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Run now for Test Agent" }));
await waitFor(() => {
expect(mockStartAgentRun).toHaveBeenCalledWith("agent-001", undefined, {
source: "on_demand",
triggerDetail: "Triggered from dashboard",
});
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Heartbeat run started for Test Agent", "success");
});
await user.click(screen.getByText("Runs"));
const initialRunFetchCalls = mockFetchAgentRuns.mock.calls.length;
await waitFor(() => {
expect(initialRunFetchCalls).toBeGreaterThan(0);
});
await user.click(screen.getByRole("button", { name: "Run now for Test Agent" }));
await waitFor(() => {
expect(mockFetchAgentRuns.mock.calls.length).toBeGreaterThan(initialRunFetchCalls);
});
expect(screen.getAllByRole("button", { name: "Run now for Test Agent" })).toHaveLength(1);
});
it("auto-expands the active run when opened from running control context", async () => { it("auto-expands the active run when opened from running control context", async () => {
const activeRunId = "run-001"; const activeRunId = "run-001";
mockFetchAgentRunLogs.mockResolvedValueOnce([ mockFetchAgentRunLogs.mockResolvedValueOnce([

View File

@@ -7618,7 +7618,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
}); });
it("default verificationFixRetries (omitted) results in 2 fix attempts", async () => { it("default verificationFixRetries (omitted) results in 3 fix attempts", async () => {
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123"); if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
@@ -7654,30 +7654,29 @@ describe("aiMergeTask — in-merge verification fix", () => {
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" }, { id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task], [{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
); );
// Explicitly omit verificationFixRetries to test default behavior // Use core defaults (verificationFixRetries defaults to 3)
const { verificationFixRetries: _omitVerificationFixRetries, ...settingsWithoutVerificationFixRetries } = DEFAULT_SETTINGS;
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ (store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...settingsWithoutVerificationFixRetries, ...DEFAULT_SETTINGS,
testCommand: "vitest run", testCommand: "vitest run",
// verificationFixRetries is NOT set — should default to 2
}); });
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({ await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError", name: "VerificationError",
}); });
// 1 merger AI agent (attempt 1) + 2 fix agent attempts (default) = 3 calls // 1 merger AI agent (attempt 1) + 3 fix agent attempts (default) = 4 calls
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
// Verify the log shows 2 fix attempts (2 log entries per attempt: start + failure) // Verify the log shows 3 fix attempts (2 log entries per attempt: start + failure)
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls; const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const fixAttempts = logCalls.filter((call: any[]) => const fixAttempts = logCalls.filter((call: any[]) =>
typeof call[1] === "string" && call[1].includes("In-merge verification fix attempt"), typeof call[1] === "string" && call[1].includes("In-merge verification fix attempt"),
); );
// Each attempt produces 2 log entries: "attempt X/2" and "attempt X — verification still fails" // Each attempt produces 2 log entries: "attempt X/3" and "attempt X — verification still fails"
expect(fixAttempts).toHaveLength(4); expect(fixAttempts).toHaveLength(6);
expect(fixAttempts[0][1]).toContain("attempt 1/2"); expect(fixAttempts[0][1]).toContain("attempt 1/3");
expect(fixAttempts[2][1]).toContain("attempt 2/2"); expect(fixAttempts[2][1]).toContain("attempt 2/3");
expect(fixAttempts[4][1]).toContain("attempt 3/3");
}); });
}); });