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", () => {
|
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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user