Agent run fixes
This commit is contained in:
5
.changeset/fn-3893-run-now-header.md
Normal file
5
.changeset/fn-3893-run-now-header.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Move agent Run Now control into the agent detail header next to lifecycle buttons.
|
||||
@@ -66,7 +66,7 @@ describe("Agent runs UI — static analysis", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentDetailView RunsTab", () => {
|
||||
describe("AgentDetailView run controls", () => {
|
||||
it("loads runs via fetchAgentRuns API", () => {
|
||||
expect(agentDetailViewContent).toMatch(/fetchAgentRuns/);
|
||||
});
|
||||
@@ -79,8 +79,8 @@ describe("Agent runs UI — static analysis", () => {
|
||||
expect(agentDetailViewContent).toMatch(/import.*startAgentRun.*from.*api/);
|
||||
});
|
||||
|
||||
it("has Run Now button in runs tab", () => {
|
||||
expect(agentDetailViewContent).toMatch(/Run Now/);
|
||||
it("has Run Now button in header controls", () => {
|
||||
expect(agentDetailViewContent).toMatch(/agent-detail-controls[\s\S]*Run Now/);
|
||||
expect(agentDetailViewContent).toMatch(/handleRunHeartbeat/);
|
||||
});
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? "dashboard");
|
||||
const [isStreaming, setIsStreaming] = 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 [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
|
||||
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 () => {
|
||||
if (!agent) return;
|
||||
const shouldDelete = await confirm({
|
||||
@@ -592,6 +608,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Play size={14} />
|
||||
Start
|
||||
</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}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
@@ -608,6 +633,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</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" && (
|
||||
@@ -717,6 +751,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
agentName={agent.name}
|
||||
initialRunId={initialRunId}
|
||||
preferActiveRun={preferActiveRun}
|
||||
runNowRefreshToken={runNowRefreshToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1443,6 +1478,7 @@ function RunsTab({
|
||||
agentName,
|
||||
initialRunId,
|
||||
preferActiveRun,
|
||||
runNowRefreshToken,
|
||||
}: {
|
||||
addToast: (msg: string, type?: "success" | "error") => void;
|
||||
agentId: string;
|
||||
@@ -1451,6 +1487,7 @@ function RunsTab({
|
||||
agentName?: string;
|
||||
initialRunId?: string | null;
|
||||
preferActiveRun?: boolean;
|
||||
runNowRefreshToken: number;
|
||||
}) {
|
||||
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
|
||||
const { confirm } = useConfirm();
|
||||
@@ -1461,6 +1498,7 @@ function RunsTab({
|
||||
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
||||
const hasAutoExpandedInitialRunRef = useRef(false);
|
||||
const didMountRunNowRefreshRef = useRef(false);
|
||||
|
||||
// Load runs on mount
|
||||
const loadRuns = useCallback(async () => {
|
||||
@@ -1478,6 +1516,15 @@ function RunsTab({
|
||||
void loadRuns();
|
||||
}, [loadRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didMountRunNowRefreshRef.current) {
|
||||
didMountRunNowRefreshRef.current = true;
|
||||
return;
|
||||
}
|
||||
setIsLoadingRuns(true);
|
||||
void loadRuns();
|
||||
}, [loadRuns, runNowRefreshToken]);
|
||||
|
||||
// Poll for active runs
|
||||
const hasActiveRun = runs.some(r => r.status === "active");
|
||||
const selectedRunStatus = selectedRunId
|
||||
@@ -1565,16 +1612,6 @@ function RunsTab({
|
||||
}
|
||||
}, [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 shouldStop = await confirm({
|
||||
@@ -1596,7 +1633,6 @@ function RunsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const canRunHeartbeat = agentState === "active" || agentState === "idle";
|
||||
|
||||
if (isLoadingRuns && runs.length === 0) {
|
||||
return (
|
||||
@@ -1612,17 +1648,6 @@ function RunsTab({
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<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">
|
||||
<Activity size={48} opacity={0.3} />
|
||||
<p>No runs yet</p>
|
||||
@@ -1854,32 +1879,23 @@ function RunsTab({
|
||||
|
||||
return (
|
||||
<div className="runs-tab">
|
||||
{canRunHeartbeat && (
|
||||
<div className="runs-toolbar runs-toolbar--between">
|
||||
<span className="runs-toolbar-meta">
|
||||
{runs.length} run{runs.length !== 1 ? "s" : ""}
|
||||
{hasActiveRun && <span className="run-live-indicator run-live-indicator--with-margin"><span className="live-dot" />Live</span>}
|
||||
</span>
|
||||
<div className="run-header-group">
|
||||
{hasActiveRun && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStopRun()}
|
||||
aria-label={`Stop active run for ${agentName ?? agentId}`}
|
||||
>
|
||||
<Square size={14} /> Stop Run
|
||||
</button>
|
||||
)}
|
||||
<div className="runs-toolbar runs-toolbar--between">
|
||||
<span className="runs-toolbar-meta">
|
||||
{runs.length} run{runs.length !== 1 ? "s" : ""}
|
||||
{hasActiveRun && <span className="run-live-indicator run-live-indicator--with-margin"><span className="live-dot" />Live</span>}
|
||||
</span>
|
||||
<div className="run-header-group">
|
||||
{hasActiveRun && (
|
||||
<button
|
||||
className="btn btn--sm btn-task-create"
|
||||
onClick={() => void handleRunHeartbeat()}
|
||||
aria-label={`Run now for ${agentName ?? agentId}`}
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStopRun()}
|
||||
aria-label={`Stop active run for ${agentName ?? agentId}`}
|
||||
>
|
||||
<Activity size={14} /> Run Now
|
||||
<Square size={14} /> Stop Run
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{activeRuns.map((run, i) => renderRunCard(run, i, true))}
|
||||
{completedRuns.map((run, i) => renderRunCard(run, activeRuns.length + i, false))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
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 "@testing-library/jest-dom";
|
||||
import { AgentDetailView } from "../AgentDetailView";
|
||||
@@ -161,7 +161,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
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";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
@@ -192,6 +192,7 @@ const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
|
||||
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
|
||||
const mockFetchAgentMailbox = vi.mocked(fetchAgentMailbox);
|
||||
const mockMarkMessageRead = vi.mocked(markMessageRead);
|
||||
const mockStartAgentRun = vi.mocked(startAgentRun);
|
||||
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
|
||||
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
|
||||
const mockFetchCompanies = vi.mocked(fetchCompanies);
|
||||
@@ -242,6 +243,7 @@ describe("AgentDetailView", () => {
|
||||
mockSubscribeSse.mockReturnValue(vi.fn());
|
||||
const mockAgent = createMockAgent();
|
||||
mockFetchAgent.mockResolvedValue(mockAgent);
|
||||
mockStartAgentRun.mockResolvedValue({ id: "run-003" } as any);
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{ id: "agent-001", name: "Test Agent", role: "executor", state: "active", metadata: {} },
|
||||
{ id: "agent-002", name: "Manager Agent", role: "reviewer", state: "active", metadata: {} },
|
||||
@@ -1679,6 +1681,102 @@ describe("AgentDetailView", () => {
|
||||
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 () => {
|
||||
const activeRunId = "run-001";
|
||||
mockFetchAgentRunLogs.mockResolvedValueOnce([
|
||||
|
||||
@@ -7618,7 +7618,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
|
||||
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) => {
|
||||
const cmdStr = String(cmd);
|
||||
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", column: "in-review" } as Task],
|
||||
);
|
||||
// Explicitly omit verificationFixRetries to test default behavior
|
||||
const { verificationFixRetries: _omitVerificationFixRetries, ...settingsWithoutVerificationFixRetries } = DEFAULT_SETTINGS;
|
||||
// Use core defaults (verificationFixRetries defaults to 3)
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...settingsWithoutVerificationFixRetries,
|
||||
...DEFAULT_SETTINGS,
|
||||
testCommand: "vitest run",
|
||||
// verificationFixRetries is NOT set — should default to 2
|
||||
});
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
|
||||
name: "VerificationError",
|
||||
});
|
||||
|
||||
// 1 merger AI agent (attempt 1) + 2 fix agent attempts (default) = 3 calls
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
|
||||
// 1 merger AI agent (attempt 1) + 3 fix agent attempts (default) = 4 calls
|
||||
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 fixAttempts = logCalls.filter((call: any[]) =>
|
||||
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"
|
||||
expect(fixAttempts).toHaveLength(4);
|
||||
expect(fixAttempts[0][1]).toContain("attempt 1/2");
|
||||
expect(fixAttempts[2][1]).toContain("attempt 2/2");
|
||||
// Each attempt produces 2 log entries: "attempt X/3" and "attempt X — verification still fails"
|
||||
expect(fixAttempts).toHaveLength(6);
|
||||
expect(fixAttempts[0][1]).toContain("attempt 1/3");
|
||||
expect(fixAttempts[2][1]).toContain("attempt 2/3");
|
||||
expect(fixAttempts[4][1]).toContain("attempt 3/3");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user