FN-7999: show failed-banner diagnostics and model/node retry
Expose a richer Task Failed banner with tool-error diagnostics and one-click retry using a different model or node. - Always show the failed banner for failed tasks (including errorless failures) with a generic reason fallback - Surface the latest agent-log tool_error detail and a retry hint for workflow/step-execute failures - Add Retry and Retry with a different model/node actions with deferred model/node override save on confirm - Style the banner recovery controls and cover them in TaskDetailModal tests - Add minor changeset for @runfusion/fusion Files changed: .changeset/fn-7999-failed-banner-retry.md | 7 + .../dashboard/app/components/TaskDetailModal.css | 47 +++++++ .../dashboard/app/components/TaskDetailModal.tsx | 143 ++++++++++++++++++++- .../__tests__/TaskDetailModal.test-helpers.ts | 3 +- .../components/__tests__/TaskDetailModal.test.tsx | 59 ++++++++- 5 files changed, 248 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7999 Fusion-Task-Lineage: d4268c43-442f-4f39-afbe-c6f583ec0fc0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7999-failed-banner-retry.md
Normal file
7
.changeset/fn-7999-failed-banner-retry.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Add a diagnostic summary and one-click "Retry with a different model/node" to the Task Failed banner.
|
||||||
|
category: feature
|
||||||
|
dev: TaskDetailModal now renders the banner for all failed tasks (including errorless), surfaces the latest tool_error detail (FN-7995), and applies model/node overrides via updateTask before re-running the existing retry path.
|
||||||
@@ -1062,6 +1062,53 @@ for a "skipped" outcome upstream).
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-error-detail,
|
||||||
|
.detail-error-hint {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-error-detail {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-error-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-error-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-error-retry-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-error-retry-picker .select {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.detail-error-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-error-actions .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.task-pause-reason {
|
.task-pause-reason {
|
||||||
margin: var(--space-md) 0 var(--space-lg);
|
margin: var(--space-md) 0 var(--space-lg);
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflo
|
|||||||
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
|
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
|
||||||
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
|
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
|
||||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
||||||
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, api } from "../api";
|
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api";
|
||||||
import type { RevertTaskOptions, RevertTaskResult } from "../api";
|
import type { RevertTaskOptions, RevertTaskResult, ModelInfo, NodeInfo } from "../api";
|
||||||
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||||
import { WorkflowIcon } from "./WorkflowIcon";
|
import { WorkflowIcon } from "./WorkflowIcon";
|
||||||
import { ApiRequestError } from "../api";
|
import { ApiRequestError } from "../api";
|
||||||
@@ -1030,6 +1030,12 @@ export function TaskDetailContent({
|
|||||||
|
|
||||||
// Edit mode state
|
// Edit mode state
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [showFailureRetryPicker, setShowFailureRetryPicker] = useState(false);
|
||||||
|
const [failureRetryModels, setFailureRetryModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [failureRetryNodes, setFailureRetryNodes] = useState<NodeInfo[]>([]);
|
||||||
|
const [failureRetryModel, setFailureRetryModel] = useState("");
|
||||||
|
const [failureRetryNodeId, setFailureRetryNodeId] = useState("");
|
||||||
|
const [isFailureRetrySaving, setIsFailureRetrySaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== "chat" || isEditing) {
|
if (activeTab !== "chat" || isEditing) {
|
||||||
@@ -2307,7 +2313,7 @@ export function TaskDetailContent({
|
|||||||
loadingMore: agentLogLoadingMore,
|
loadingMore: agentLogLoadingMore,
|
||||||
} = useAgentLogs(
|
} = useAgentLogs(
|
||||||
task.id,
|
task.id,
|
||||||
activeTab === "chat" && activitySegment === "raw-logs",
|
task.status === "failed" || (activeTab === "chat" && activitySegment === "raw-logs"),
|
||||||
projectId,
|
projectId,
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2591,7 +2597,50 @@ export function TaskDetailContent({
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
addToast(getErrorMessage(err), "error");
|
addToast(getErrorMessage(err), "error");
|
||||||
});
|
});
|
||||||
}, [task.id, onRetryTask, requestClose, addToast]);
|
}, [task.id, onRetryTask, requestClose, addToast, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showFailureRetryPicker) return;
|
||||||
|
setFailureRetryModel(task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "");
|
||||||
|
setFailureRetryNodeId(task.nodeId ?? "");
|
||||||
|
void Promise.all([fetchModels(), fetchNodes()])
|
||||||
|
.then(([models, nodes]) => {
|
||||||
|
setFailureRetryModels(models.models);
|
||||||
|
setFailureRetryNodes(nodes);
|
||||||
|
})
|
||||||
|
.catch((err) => addToast(getErrorMessage(err) || t("taskDetail.error.retryOptionsFailed", "Failed to load retry options"), "error"));
|
||||||
|
}, [addToast, showFailureRetryPicker, t, task.id, task.modelId, task.modelProvider, task.nodeId]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:TaskFailedBanner 2026-07-15-16:30:
|
||||||
|
The failed-banner picker stages model/node choices and writes one per-task override
|
||||||
|
only when the operator confirms Retry. RoutingTab saves on selection, which would
|
||||||
|
leave an abandoned override when the operator closes this recovery picker.
|
||||||
|
*/
|
||||||
|
const handleRetryWithOverride = useCallback(async () => {
|
||||||
|
if (!onRetryTask || isFailureRetrySaving) return;
|
||||||
|
const modelSelection = splitModelSelection(failureRetryModel);
|
||||||
|
const currentModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
|
||||||
|
const hasModelChange = failureRetryModel !== currentModel;
|
||||||
|
const hasNodeChange = failureRetryNodeId !== (task.nodeId ?? "");
|
||||||
|
if (!hasModelChange && !hasNodeChange) return;
|
||||||
|
|
||||||
|
setIsFailureRetrySaving(true);
|
||||||
|
try {
|
||||||
|
const updatedTask = await updateTask(task.id, {
|
||||||
|
...(hasModelChange ? { modelProvider: modelSelection?.provider ?? null, modelId: modelSelection?.modelId ?? null } : {}),
|
||||||
|
...(hasNodeChange ? { nodeId: failureRetryNodeId || null } : {}),
|
||||||
|
}, projectId);
|
||||||
|
onTaskUpdated?.(updatedTask);
|
||||||
|
await onRetryTask(task.id);
|
||||||
|
addToast(t("taskDetail.retry.retried", "Retried {{id}}", { id: task.id }), "success");
|
||||||
|
requestClose();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(getErrorMessage(err), "error");
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setIsFailureRetrySaving(false);
|
||||||
|
}
|
||||||
|
}, [addToast, failureRetryModel, failureRetryNodeId, isFailureRetrySaving, onRetryTask, onTaskUpdated, projectId, requestClose, t, task.id, task.modelId, task.modelProvider, task.nodeId]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:ReviewLaneBypass 2026-07-09-00:00:
|
FNXC:ReviewLaneBypass 2026-07-09-00:00:
|
||||||
@@ -3328,7 +3377,22 @@ export function TaskDetailContent({
|
|||||||
FNXC:TaskDetailPlannerChat 2026-07-01-00:00:
|
FNXC:TaskDetailPlannerChat 2026-07-01-00:00:
|
||||||
Maximized Planner Chat reserves vertical room for task identity and the planner conversation, so failed-task chrome is not mounted in that state. Normal detail, Activity expansion, and collapsed Planner Chat still surface task failures immediately.
|
Maximized Planner Chat reserves vertical room for task identity and the planner conversation, so failed-task chrome is not mounted in that state. Normal detail, Activity expansion, and collapsed Planner Chat still surface task failures immediately.
|
||||||
*/
|
*/
|
||||||
const shouldShowTaskFailureAlert = Boolean(task.status === "failed" && task.error && !isPlannerChatExpanded);
|
/*
|
||||||
|
FNXC:TaskFailedBanner 2026-07-15-16:30:
|
||||||
|
Failed tasks must always expose recovery controls, including legacy/errorless failures,
|
||||||
|
without mounting an empty error-message shell. The default banner fetches agent logs
|
||||||
|
independently of the Raw Logs segment because FN-7995 persists bounded `tool_error`
|
||||||
|
detail there; the Raw-Logs-gated display list is not a diagnostic data source.
|
||||||
|
*/
|
||||||
|
const shouldShowTaskFailureAlert = Boolean(task.status === "failed" && !isPlannerChatExpanded);
|
||||||
|
const taskFailureReason = task.error?.trim() || t("taskDetail.error.genericFailureReason", "The task failed before it could complete.");
|
||||||
|
const taskFailureToolDetail = useMemo(() => {
|
||||||
|
const lastToolError = [...agentLogEntries].reverse().find((entry) => entry.type === "tool_error" && entry.detail?.trim());
|
||||||
|
return lastToolError?.detail?.trim().slice(0, 1024);
|
||||||
|
}, [agentLogEntries]);
|
||||||
|
const taskFailureHint = /workflow graph terminated|step-execute|no files? (were )?modified/i.test(`${task.error ?? ""}\n${taskFailureToolDetail ?? ""}`)
|
||||||
|
? t("taskDetail.error.retryHint", "Consider retrying with a different model or node.")
|
||||||
|
: null;
|
||||||
|
|
||||||
const taskActionMenuModel = useMemo(() => buildTaskActionMenuModel({
|
const taskActionMenuModel = useMemo(() => buildTaskActionMenuModel({
|
||||||
task,
|
task,
|
||||||
@@ -4492,11 +4556,76 @@ export function TaskDetailContent({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{shouldShowTaskFailureAlert && (
|
{shouldShowTaskFailureAlert && (
|
||||||
<div className="detail-error-alert">
|
<div className="detail-error-alert" role="alert">
|
||||||
<span className="detail-error-icon">⚠</span>
|
<span className="detail-error-icon">⚠</span>
|
||||||
<div className="detail-error-content">
|
<div className="detail-error-content">
|
||||||
<div className="detail-error-title">{t("taskDetail.error.taskFailed", "Task Failed")}</div>
|
<div className="detail-error-title">{t("taskDetail.error.taskFailed", "Task Failed")}</div>
|
||||||
<div className="detail-error-message">{task.error}</div>
|
<div className="detail-error-message">{taskFailureReason}</div>
|
||||||
|
{taskFailureToolDetail ? (
|
||||||
|
<div className="detail-error-detail">
|
||||||
|
{taskFailureToolDetail}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{taskFailureHint ? <div className="detail-error-hint">{taskFailureHint}</div> : null}
|
||||||
|
{onRetryTask && canRetryTask ? (
|
||||||
|
<div className="detail-error-actions">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={handleRetry}>
|
||||||
|
{t("taskDetail.error.retry", "Retry")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setShowFailureRetryPicker(true)}>
|
||||||
|
{t("taskDetail.error.retryWithModel", "Retry with a different model/node")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showFailureRetryPicker && onRetryTask && canRetryTask ? (
|
||||||
|
<div className="detail-error-retry-picker">
|
||||||
|
<label htmlFor={`failure-retry-model-${task.id}`}>
|
||||||
|
{t("taskDetail.error.retryModelLabel", "Executor model")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`failure-retry-model-${task.id}`}
|
||||||
|
className="select"
|
||||||
|
value={failureRetryModel}
|
||||||
|
disabled={isFailureRetrySaving}
|
||||||
|
onChange={(event) => setFailureRetryModel(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("taskDetail.error.retryModelDefault", "Use project default")}</option>
|
||||||
|
{failureRetryModels.map((model) => (
|
||||||
|
<option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>
|
||||||
|
{model.provider}/{model.name || model.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label htmlFor={`failure-retry-node-${task.id}`}>
|
||||||
|
{t("taskDetail.error.retryNodeLabel", "Execution node")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`failure-retry-node-${task.id}`}
|
||||||
|
className="select"
|
||||||
|
value={failureRetryNodeId}
|
||||||
|
disabled={isFailureRetrySaving}
|
||||||
|
onChange={(event) => setFailureRetryNodeId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("taskDetail.error.retryNodeDefault", "Use project default")}</option>
|
||||||
|
{failureRetryNodes.map((node) => (
|
||||||
|
<option key={node.id} value={node.id}>{node.name} ({node.type})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="detail-error-actions">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setShowFailureRetryPicker(false)} disabled={isFailureRetrySaving}>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => void handleRetryWithOverride()}
|
||||||
|
disabled={isFailureRetrySaving || (failureRetryModel === (task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "") && failureRetryNodeId === (task.nodeId ?? ""))}
|
||||||
|
>
|
||||||
|
{t("taskDetail.error.confirmRetry", "Apply and retry")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
assignTask: vi.fn().mockResolvedValue({}),
|
assignTask: vi.fn().mockResolvedValue({}),
|
||||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||||
fetchAgent: vi.fn().mockResolvedValue(null),
|
fetchAgent: vi.fn().mockResolvedValue(null),
|
||||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
|
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||||
|
fetchNodes: vi.fn().mockResolvedValue([]),
|
||||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||||
|
|||||||
@@ -296,7 +296,8 @@ describe("TaskDetailModal planner Chat tab", () => {
|
|||||||
expect(container.querySelector(".detail-error-alert")).toBeInTheDocument();
|
expect(container.querySelector(".detail-error-alert")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render empty failed-task alert shells for non-failed or errorless failed tasks", () => {
|
it("renders an actionable generic failed-task alert without an empty message shell", async () => {
|
||||||
|
const onRetryTask = vi.fn().mockResolvedValue(makeTask());
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="planner-chat"
|
initialTab="planner-chat"
|
||||||
@@ -307,12 +308,16 @@ describe("TaskDetailModal planner Chat tab", () => {
|
|||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
onMergeTask={noopMerge}
|
onMergeTask={noopMerge}
|
||||||
onOpenDetail={noopOpenDetail}
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onRetryTask={onRetryTask}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByText("Task Failed")).not.toBeInTheDocument();
|
expect(screen.getByText("Task Failed")).toBeInTheDocument();
|
||||||
expect(container.querySelector(".detail-error-alert")).toBeNull();
|
expect(screen.getByText("The task failed before it could complete.")).toBeInTheDocument();
|
||||||
|
expect(container.querySelector(".detail-error-message")?.textContent).not.toBe("");
|
||||||
|
await userEvent.setup().click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(onRetryTask).toHaveBeenCalledWith("FN-099");
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -331,6 +336,54 @@ describe("TaskDetailModal planner Chat tab", () => {
|
|||||||
expect(screen.queryByText("Task Failed")).not.toBeInTheDocument();
|
expect(screen.queryByText("Task Failed")).not.toBeInTheDocument();
|
||||||
expect(container.querySelector(".detail-error-alert")).toBeNull();
|
expect(container.querySelector(".detail-error-alert")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces the latest tool error detail and stages a model override before retrying", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||||
|
const { fetchModels, fetchNodes, updateTask } = await import("../../api");
|
||||||
|
vi.mocked(useAgentLogs).mockReturnValue({
|
||||||
|
entries: [
|
||||||
|
{ timestamp: "2026-07-15T16:00:00Z", taskId: "FN-099", text: "older tool", type: "tool_error", detail: "Older failure" },
|
||||||
|
{ timestamp: "2026-07-15T16:01:00Z", taskId: "FN-099", text: "write", type: "tool_error", detail: "Permission denied while writing the requested file" },
|
||||||
|
],
|
||||||
|
loading: false,
|
||||||
|
clear: vi.fn(),
|
||||||
|
loadMore: vi.fn(async () => {}),
|
||||||
|
hasMore: false,
|
||||||
|
total: 2,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
vi.mocked(fetchModels).mockResolvedValue({ models: [{ provider: "anthropic", id: "claude-alternate", name: "Claude Alternate", reasoning: true, contextWindow: 200000 }], favoriteProviders: [], favoriteModels: [] });
|
||||||
|
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-alternate", name: "Alternate node", type: "remote", status: "online", maxConcurrent: 1, createdAt: "", updatedAt: "" }]);
|
||||||
|
vi.mocked(updateTask).mockResolvedValue(makeTask({ modelProvider: "anthropic", modelId: "claude-alternate" }));
|
||||||
|
const onRetryTask = vi.fn().mockResolvedValue(makeTask());
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
initialTab="planner-chat"
|
||||||
|
taskDetailChatFirst
|
||||||
|
task={makeTask({ column: "todo" as any, status: "failed", error: "Workflow graph terminated with failure at node 'steps#0:step-execute'" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onRetryTask={onRetryTask}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Permission denied while writing the requested file")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Consider retrying with a different model or node.")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Retry with a different model/node" }));
|
||||||
|
await screen.findByLabelText("Executor model");
|
||||||
|
await user.selectOptions(screen.getByLabelText("Executor model"), "anthropic/claude-alternate");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply and retry" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateTask).toHaveBeenCalledWith("FN-099", { modelProvider: "anthropic", modelId: "claude-alternate" }, undefined));
|
||||||
|
await waitFor(() => expect(onRetryTask).toHaveBeenCalledWith("FN-099"));
|
||||||
|
vi.mocked(useAgentLogs).mockReturnValue({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskDetailModal summarize title action", () => {
|
describe("TaskDetailModal summarize title action", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user