FN-7139: show task columns on agent badges
Enrich dashboard agent task indicators with linked task column context. - Add API-side transient taskColumn enrichment for active agent task links, including an unresolved sentinel for missing tasks. - Render shared agent task badges across agent panels, detail views, lists, and mobile surfaces. - Extend dashboard/API coverage and document the task-column badge behavior with a published changeset. Files changed: .changeset/fn-7139-agent-task-column-context.md | 7 ++ docs/dashboard-guide.md | 2 + packages/core/src/types.ts | 5 ++ .../dashboard/app/components/ActiveAgentsPanel.tsx | 3 +- .../dashboard/app/components/AgentDetailView.tsx | 5 +- .../dashboard/app/components/AgentListModal.tsx | 3 +- .../dashboard/app/components/AgentTaskBadge.tsx | 28 +++++++ packages/dashboard/app/components/AgentsView.tsx | 3 +- .../__tests__/ActiveAgentsPanel.test.tsx | 55 ++++++++++--- .../__tests__/AgentDetailView.core.test.tsx | 65 +++++++++++++++ .../AgentDetailView.mobile-scroll.test.tsx | 16 +++- .../components/__tests__/AgentListModal.test.tsx | 16 +++- .../app/components/__tests__/AgentsView.test.tsx | 23 +++++- .../dashboard/src/__tests__/routes-agents.test.ts | 92 +++++++++++++++++++++- packages/dashboard/src/routes.ts | 13 ++- 15 files changed, 306 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-7139 Fusion-Task-Lineage: 3f6d9470-b98f-4bd3-a91e-7e69ab624d48 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7139-agent-task-column-context.md
Normal file
7
.changeset/fn-7139-agent-task-column-context.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show linked task columns on dashboard agent task badges.
|
||||
category: fix
|
||||
dev: Adds transient agent taskColumn enrichment for dashboard agent list, detail, and live-agent surfaces.
|
||||
@@ -711,6 +711,8 @@ Features:
|
||||
- Switch between **List**, **Board**, and **Org chart** layouts
|
||||
- Filter by role/state, include/exclude system agents, and inspect health/status
|
||||
- Agent list cards show the configured **Model** or plugin **Runtime** for each agent, falling back to **Auto** when no override is set
|
||||
<!-- FNXC:AgentTaskStateDrift 2026-06-27-16:46: Agent task badges include the linked task column so parked `triage`/`todo` ownership from the FN-7138 invariant is not misread as execution drift. -->
|
||||
- Agent list, live-agent, and detail task badges show the linked task ID with its current column when the task is non-terminal (for example `FN-6902 · Triage` or `FN-6902 · In Progress`). Terminal linked tasks are omitted, and unresolved column lookups render an explicit `Unresolved task` suffix so missing or deleted task links are not mistaken for healthy parked work.
|
||||
- First-run setup asks whether to create an optional project agent after project registration. The default template is **CEO**; users can choose another preset, use the AI interview when `experimentalFeatures.agentOnboarding` is enabled, or skip it. Fusion can still build tasks without an agent by starting temporary agents to plan, code, review, and merge task work.
|
||||
- Start, pause, stop, and trigger agent runs from the view and from detail panels
|
||||
- In **Agent detail**, use the kebab **Bulk agent actions** button in the header utility cluster (next to **Refresh** and **Close**) to run project-wide lifecycle transitions for non-ephemeral agents in the current project — **Pause All Agents** targets agents in the `active` or `running` state, while **Resume All Agents** targets agents in the `paused` state only
|
||||
|
||||
@@ -6534,6 +6534,11 @@ export interface Agent {
|
||||
lastError?: string;
|
||||
/** Number of currently pending approvals requested by this agent. */
|
||||
pendingApprovalCount?: number;
|
||||
/**
|
||||
* FNXC:AgentTaskStateDrift 2026-06-27-16:20:
|
||||
* Dashboard/API responses need a transient linked-task column so coordinators can distinguish legitimate parked/active agent linkages from execution drift; unresolved lookups use the response-only "unresolved" sentinel. This is resolved per request and must not be persisted by AgentStore.
|
||||
*/
|
||||
taskColumn?: string;
|
||||
/** Path to a markdown file containing custom instructions (resolved relative to project root).
|
||||
* Must end in `.md`, no `..` traversal. Max 500 chars. */
|
||||
instructionsPath?: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fetchTaskDetail } from "../api";
|
||||
import "./ActiveAgentsPanel.css";
|
||||
import { useLiveTranscript } from "../hooks/useLiveTranscript";
|
||||
import { resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals";
|
||||
import { AgentTaskBadge } from "./AgentTaskBadge";
|
||||
|
||||
interface LiveAgentCardProps {
|
||||
agent: Agent;
|
||||
@@ -113,7 +114,7 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
<span>{agent.name}</span>
|
||||
</div>
|
||||
{agent.taskId && (
|
||||
<span className="live-agent-task badge">{agent.taskId}</span>
|
||||
<span className="live-agent-task badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="live-agent-card-transcript">
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
import { AgentTaskBadge } from "./AgentTaskBadge";
|
||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
|
||||
import { useFavorites } from "../hooks/useFavorites";
|
||||
@@ -1028,7 +1029,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<span className="divider">|</span>
|
||||
<span className="text-muted">{t("agents.workingOn", "Working on:")}</span>
|
||||
<a href={`/tasks/${agent.taskId}`} className="link">
|
||||
{agent.taskId}
|
||||
<AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} />
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</>
|
||||
@@ -1301,7 +1302,7 @@ function DashboardTab({
|
||||
<h3>{t("agents.currentWork", "Current Work")}</h3>
|
||||
{agent.taskId ? (
|
||||
<div className="current-task">
|
||||
<a href={`/tasks/${agent.taskId}`} className="task-badge">{agent.taskId}</a>
|
||||
<a href={`/tasks/${agent.taskId}`} className="task-badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></a>
|
||||
<a href={`/tasks/${agent.taskId}`} className="btn btn-sm">{t("agents.viewTask", "View Task")} <ExternalLink size={14} /></a>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { AgentHealthStatus } from "../utils/agentHealth";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
import { AgentTaskBadge } from "./AgentTaskBadge";
|
||||
|
||||
interface AgentListModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -584,7 +585,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
{agent.taskId && (
|
||||
<div className="agent-task">
|
||||
<span className="text-secondary">{t("agents.workingOn", "Working on:")}</span>
|
||||
<span className="badge">{agent.taskId}</span>
|
||||
<span className="badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
|
||||
</div>
|
||||
)}
|
||||
{agent.lastHeartbeatAt && (
|
||||
|
||||
28
packages/dashboard/app/components/AgentTaskBadge.tsx
Normal file
28
packages/dashboard/app/components/AgentTaskBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ColumnId } from "@fusion/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
|
||||
const UNRESOLVED_AGENT_TASK_COLUMN = "unresolved";
|
||||
|
||||
interface AgentTaskBadgeProps {
|
||||
taskId: string;
|
||||
taskColumn?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:AgentTaskStateDrift 2026-06-27-16:20:
|
||||
* Agent task badges include the linked task column to disambiguate legitimate triage/queued linkage from execution drift.
|
||||
*
|
||||
* FNXC:AgentTaskStateDrift 2026-06-27-17:08:
|
||||
* Unresolved linked tasks need an explicit badge suffix so missing/deleted tasks do not look like a merely un-enriched response.
|
||||
*/
|
||||
export function AgentTaskBadge({ taskId, taskColumn }: AgentTaskBadgeProps) {
|
||||
const columnLabel = useColumnLabel();
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
if (!taskColumn || taskColumn === UNRESOLVED_AGENT_TASK_COLUMN) {
|
||||
return <>{taskId} · {t("agents.taskColumnUnresolved", "Unresolved task")}</>;
|
||||
}
|
||||
|
||||
return <>{taskId} · {columnLabel(taskColumn as ColumnId)}</>;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "./agentsOrgChartLayout";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
import { AgentTaskBadge } from "./AgentTaskBadge";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
@@ -1886,7 +1887,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
{agent.taskId && (
|
||||
<div className="agent-task">
|
||||
<span className="text-secondary">{t("agents.workingOn", "Working on:")}</span>
|
||||
<span className="badge">{agent.taskId}</span>
|
||||
<span className="badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-heartbeat-control">
|
||||
|
||||
@@ -300,25 +300,56 @@ describe("ActiveAgentsPanel", () => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("displays agent name and task badge", async () => {
|
||||
it("displays agent task badges with column context and unresolved fallback", async () => {
|
||||
mockUseLiveTranscript.mockReturnValue({
|
||||
entries: [],
|
||||
isConnected: false,
|
||||
});
|
||||
|
||||
const mockAgent: Agent = {
|
||||
id: "agent-001",
|
||||
name: "My Agent",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
taskId: "FN-042",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
} as Agent;
|
||||
const agents: Agent[] = [
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Triage Agent",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
taskId: "FN-TRIAGE",
|
||||
taskColumn: "triage",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
} as Agent,
|
||||
{
|
||||
id: "agent-002",
|
||||
name: "Progress Agent",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
taskId: "FN-PROGRESS",
|
||||
taskColumn: "in-progress",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
} as Agent,
|
||||
{
|
||||
id: "agent-003",
|
||||
name: "Bare Agent",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
taskId: "FN-BARE",
|
||||
taskColumn: "unresolved",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
} as Agent,
|
||||
{
|
||||
id: "agent-004",
|
||||
name: "No Task Agent",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
} as Agent,
|
||||
];
|
||||
|
||||
render(<ActiveAgentsPanel agents={[mockAgent]} />);
|
||||
const { container } = render(<ActiveAgentsPanel agents={agents} />);
|
||||
|
||||
expect(screen.getByText("My Agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("FN-042")).toBeInTheDocument();
|
||||
expect(screen.getByText("Triage Agent")).toBeInTheDocument();
|
||||
expect(screen.getByText((_, el) => el?.textContent === "FN-TRIAGE · Planning")).toBeInTheDocument();
|
||||
expect(screen.getByText((_, el) => el?.textContent === "FN-PROGRESS · In Progress")).toBeInTheDocument();
|
||||
expect(screen.getByText((_, el) => el?.textContent === "FN-BARE · Unresolved task")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".live-agent-task")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("calls onAgentSelect with agent ID when card is clicked", async () => {
|
||||
|
||||
@@ -270,6 +270,71 @@ it("uses global design tokens instead of component-local aliases", async () => {
|
||||
expect(stylesContent).toMatch(/--card-hover:/);
|
||||
});
|
||||
|
||||
it("displays linked task column context in header and current work", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-TRIAGE", taskColumn: "triage" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-TRIAGE · Planning").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays in-progress linked task column context", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-PROGRESS", taskColumn: "in-progress" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-PROGRESS · In Progress").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays unresolved linked task context when column enrichment is missing", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-BARE", taskColumn: undefined }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-BARE · Unresolved task").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render task badge shells without a linked task", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: undefined, taskColumn: undefined }));
|
||||
|
||||
const { container } = render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No active assignment")).toBeInTheDocument();
|
||||
});
|
||||
expect(container.querySelector(".task-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("displays agent name in header after loading", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||
import { setupAgentDetailMocks } from "./AgentDetailView.test-helpers";
|
||||
import { createMockAgent, mockFetchAgent, setupAgentDetailMocks } from "./AgentDetailView.test-helpers";
|
||||
import { AgentDetailView } from "../AgentDetailView";
|
||||
|
||||
function installAgentDetailMatchMedia(matchesMobile: boolean) {
|
||||
@@ -51,6 +51,18 @@ describe("AgentDetailView mobile scroll regression (FN-4231)", () => {
|
||||
expect(window.getComputedStyle(footerEl).flexShrink).toBe("0");
|
||||
});
|
||||
|
||||
it("shows mobile task column context without empty task shells (FN-7139)", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-MOBILE", taskColumn: "in-progress" }));
|
||||
|
||||
const { container } = render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-MOBILE · In Progress").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
expect(container.querySelector(".agent-detail-content")).toBeTruthy();
|
||||
expect(container.querySelector(".task-badge")?.textContent).toContain("FN-MOBILE · In Progress");
|
||||
});
|
||||
|
||||
it("tabs accept horizontal touch panning and stay non-shrinking on mobile (FN-6450, FN-6865)", async () => {
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -248,8 +248,15 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("displays task ID when agent is working on a task", async () => {
|
||||
render(
|
||||
it("displays task ID with column context when agent is working on a task", async () => {
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{ ...mockAgents[0], id: "agent-triage", name: "Triage Agent", taskId: "FN-TRIAGE", taskColumn: "triage", state: "active" as AgentState },
|
||||
{ ...mockAgents[1], id: "agent-progress", name: "Progress Agent", taskId: "FN-PROGRESS", taskColumn: "in-progress", state: "running" as AgentState },
|
||||
{ ...mockAgents[2], id: "agent-bare", name: "Bare Agent", taskId: "FN-BARE", taskColumn: "unresolved" },
|
||||
{ ...mockAgents[3], id: "agent-none", name: "No Task Agent" },
|
||||
]);
|
||||
|
||||
const { container } = render(
|
||||
<AgentListModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
@@ -258,8 +265,11 @@ describe("AgentListModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("FN-001")).toBeTruthy();
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-TRIAGE · Planning").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-PROGRESS · In Progress").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-BARE · Unresolved task").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(container.querySelectorAll(".agent-task").length).toBe(3);
|
||||
});
|
||||
|
||||
it("shows empty state when no agents exist", async () => {
|
||||
|
||||
@@ -826,10 +826,29 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("displays agent task when working on one", async () => {
|
||||
it("displays agent task with column context when enriched", async () => {
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{ ...mockAgents[0], id: "agent-triage", name: "Triage Agent", taskId: "FN-TRIAGE", taskColumn: "triage", state: "active" as AgentState },
|
||||
{ ...mockAgents[1], id: "agent-progress", name: "Progress Agent", taskId: "FN-PROGRESS", taskColumn: "in-progress", state: "running" as AgentState },
|
||||
{ ...mockAgents[2], id: "agent-bare", name: "Bare Agent", taskId: "FN-BARE", taskColumn: "unresolved" },
|
||||
{ ...mockAgents[3], id: "agent-none", name: "No Task Agent" },
|
||||
]);
|
||||
mockFetchAgentStats.mockResolvedValue({ total: 4, byState: { active: 1, running: 1 }, byRole: { executor: 2 } });
|
||||
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-TRIAGE · Planning").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-PROGRESS · In Progress").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-BARE · Unresolved task").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(container.querySelectorAll(".agent-task").length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("displays unresolved context when task column is missing", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("FN-001").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent === "FN-001 · Unresolved task").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2035,7 +2035,7 @@ describe("POST /api/ai/draft-goal-description", () => {
|
||||
expect(res.body).toEqual({
|
||||
description: "Grow the ecosystem with clear extension support and measurable adoption.",
|
||||
});
|
||||
expect(draftSpy).toHaveBeenCalledWith("Grow plugin ecosystem", "/test/project", undefined);
|
||||
expect(draftSpy).toHaveBeenCalledWith("Grow plugin ecosystem", "/test/project", undefined, store);
|
||||
});
|
||||
|
||||
it("returns 400 when title is missing or empty", async () => {
|
||||
@@ -2062,6 +2062,7 @@ describe("POST /api/ai/draft-goal-description", () => {
|
||||
|
||||
it("returns 429 when draft requests are rate limited", async () => {
|
||||
const app = buildApp();
|
||||
vi.spyOn(aiRefineModule, "draftGoalDescription").mockResolvedValue("Drafted goal description.");
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await REQUEST(
|
||||
@@ -2927,6 +2928,7 @@ describe("Agent stale task-link sanitization", () => {
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent).not.toHaveProperty("taskId");
|
||||
expect(testAgent).not.toHaveProperty("taskColumn");
|
||||
});
|
||||
|
||||
it("GET /api/agents omits taskId when linked task is archived", async () => {
|
||||
@@ -2953,6 +2955,7 @@ describe("Agent stale task-link sanitization", () => {
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent).not.toHaveProperty("taskId");
|
||||
expect(testAgent).not.toHaveProperty("taskColumn");
|
||||
});
|
||||
|
||||
it("GET /api/agents preserves taskId for non-terminal linked tasks", async () => {
|
||||
@@ -2979,6 +2982,33 @@ describe("Agent stale task-link sanitization", () => {
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent.taskId).toBe(activeTaskId);
|
||||
expect(testAgent.taskColumn).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("GET /api/agents returns taskColumn for parked triage linked tasks", async () => {
|
||||
const triageTaskId = "FN-TRIAGE";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTaskColumns: vi.fn().mockResolvedValue(new Map([[triageTaskId, "triage"]])),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, triageTaskId);
|
||||
|
||||
const res = await GET(app, "/api/agents");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent.taskId).toBe(triageTaskId);
|
||||
expect(testAgent.taskColumn).toBe("triage");
|
||||
});
|
||||
|
||||
it("GET /api/agents/:id omits taskId when linked task is done", async () => {
|
||||
@@ -3004,6 +3034,7 @@ describe("Agent stale task-link sanitization", () => {
|
||||
expect(res.body).toBeDefined();
|
||||
expect(res.body.id).toBe(agentId);
|
||||
expect(res.body).not.toHaveProperty("taskId");
|
||||
expect(res.body).not.toHaveProperty("taskColumn");
|
||||
});
|
||||
|
||||
it("GET /api/agents/:id omits taskId when linked task is archived", async () => {
|
||||
@@ -3029,6 +3060,7 @@ describe("Agent stale task-link sanitization", () => {
|
||||
expect(res.body).toBeDefined();
|
||||
expect(res.body.id).toBe(agentId);
|
||||
expect(res.body).not.toHaveProperty("taskId");
|
||||
expect(res.body).not.toHaveProperty("taskColumn");
|
||||
});
|
||||
|
||||
it("GET /api/agents/:id preserves taskId for in-review linked tasks", async () => {
|
||||
@@ -3054,6 +3086,32 @@ describe("Agent stale task-link sanitization", () => {
|
||||
expect(res.body).toBeDefined();
|
||||
expect(res.body.id).toBe(agentId);
|
||||
expect(res.body.taskId).toBe(inReviewTaskId);
|
||||
expect(res.body.taskColumn).toBe("in-review");
|
||||
});
|
||||
|
||||
it("GET /api/agents/:id returns taskColumn for in-progress linked tasks", async () => {
|
||||
const inProgressTaskId = "FN-IN-PROGRESS";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTaskColumns: vi.fn().mockResolvedValue(new Map([[inProgressTaskId, "in-progress"]])),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, inProgressTaskId);
|
||||
|
||||
const res = await GET(app, `/api/agents/${agentId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeDefined();
|
||||
expect(res.body.id).toBe(agentId);
|
||||
expect(res.body.taskId).toBe(inProgressTaskId);
|
||||
expect(res.body.taskColumn).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("GET /api/agents/stats excludes terminal task links from assignedTaskCount", async () => {
|
||||
@@ -3195,7 +3253,33 @@ describe("Agent stale task-link sanitization", () => {
|
||||
expect(res.body.todoTaskCount).toBe(2);
|
||||
});
|
||||
|
||||
it("GET /api/agents handles task lookup failure gracefully", async () => {
|
||||
it("GET /api/agents marks missing linked tasks as unresolved", async () => {
|
||||
const taskId = "FN-MISSING";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTaskColumns: vi.fn().mockResolvedValue(new Map()),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, taskId);
|
||||
|
||||
const res = await GET(app, "/api/agents");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent.taskId).toBe(taskId);
|
||||
expect(testAgent.taskColumn).toBe("unresolved");
|
||||
});
|
||||
|
||||
it("GET /api/agents marks linked task lookup failures as unresolved", async () => {
|
||||
const taskId = "FN-LOOKUP-FAIL";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
@@ -3212,15 +3296,15 @@ describe("Agent stale task-link sanitization", () => {
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, taskId);
|
||||
|
||||
// Should not throw, taskId should be preserved on lookup failure
|
||||
// Should not throw; unresolved lookup state should be explicit on the response.
|
||||
const res = await GET(app, "/api/agents");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
// On lookup failure, taskId should be preserved (treated as non-terminal)
|
||||
expect(testAgent.taskId).toBe(taskId);
|
||||
expect(testAgent.taskColumn).toBe("unresolved");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3198,6 +3198,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* as "working on" in agent UI surfaces to avoid stale activity indicators.
|
||||
*/
|
||||
const TERMINAL_TASK_STATUSES = new Set(["done", "archived"]);
|
||||
const UNRESOLVED_AGENT_TASK_COLUMN = "unresolved";
|
||||
|
||||
/**
|
||||
* Check if a task status is terminal (done or archived).
|
||||
@@ -3235,10 +3236,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const taskStatus = taskStatusMap.get(agent.taskId);
|
||||
if (isTerminalTaskStatus(taskStatus)) {
|
||||
// Omit taskId for terminal tasks — use spread to create shallow copy without taskId
|
||||
const { taskId: _omitted, ...sanitized } = agent;
|
||||
const { taskId: _omitted, taskColumn: _taskColumnOmitted, ...sanitized } = agent;
|
||||
return sanitized as import("@fusion/core").Agent;
|
||||
}
|
||||
return agent;
|
||||
|
||||
/*
|
||||
* FNXC:AgentTaskStateDrift 2026-06-27-16:20:
|
||||
* Dashboard agent surfaces show the linked task column so coordinators can tell legitimate triage/queued or active linkage apart from execution drift, matching the FN-7138 text-surface invariant.
|
||||
*
|
||||
* FNXC:AgentTaskStateDrift 2026-06-27-17:08:
|
||||
* Missing/deleted linked tasks and lookup failures must be explicit too; otherwise a stale task link is indistinguishable from an un-enriched dashboard response.
|
||||
*/
|
||||
return { ...agent, taskColumn: taskStatus ?? UNRESOLVED_AGENT_TASK_COLUMN };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user