From 9e7c57da0877bf91e6d0027f8f18d2446fec14ac Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 17:10:58 -0700 Subject: [PATCH] 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) --- .../fn-7139-agent-task-column-context.md | 7 ++ docs/dashboard-guide.md | 2 + packages/core/src/types.ts | 5 + .../app/components/ActiveAgentsPanel.tsx | 3 +- .../app/components/AgentDetailView.tsx | 5 +- .../app/components/AgentListModal.tsx | 3 +- .../app/components/AgentTaskBadge.tsx | 28 ++++++ .../dashboard/app/components/AgentsView.tsx | 3 +- .../__tests__/ActiveAgentsPanel.test.tsx | 55 ++++++++--- .../__tests__/AgentDetailView.core.test.tsx | 65 +++++++++++++ .../AgentDetailView.mobile-scroll.test.tsx | 16 +++- .../__tests__/AgentListModal.test.tsx | 16 +++- .../components/__tests__/AgentsView.test.tsx | 23 ++++- .../src/__tests__/routes-agents.test.ts | 92 ++++++++++++++++++- packages/dashboard/src/routes.ts | 13 ++- 15 files changed, 306 insertions(+), 30 deletions(-) create mode 100644 .changeset/fn-7139-agent-task-column-context.md create mode 100644 packages/dashboard/app/components/AgentTaskBadge.tsx diff --git a/.changeset/fn-7139-agent-task-column-context.md b/.changeset/fn-7139-agent-task-column-context.md new file mode 100644 index 0000000000..d6737441ce --- /dev/null +++ b/.changeset/fn-7139-agent-task-column-context.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 09c2fff052..effafdff22 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 + +- 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 diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9c38ed82fd..c8e7ac2d19 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; diff --git a/packages/dashboard/app/components/ActiveAgentsPanel.tsx b/packages/dashboard/app/components/ActiveAgentsPanel.tsx index 421956b909..852c727ce8 100644 --- a/packages/dashboard/app/components/ActiveAgentsPanel.tsx +++ b/packages/dashboard/app/components/ActiveAgentsPanel.tsx @@ -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 {agent.name} {agent.taskId && ( - {agent.taskId} + )}
diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 51935bcd0c..2e2c958425 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -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 | {t("agents.workingOn", "Working on:")} - {agent.taskId} + @@ -1301,7 +1302,7 @@ function DashboardTab({

{t("agents.currentWork", "Current Work")}

{agent.taskId ? (
- {agent.taskId} + {t("agents.viewTask", "View Task")}
) : ( diff --git a/packages/dashboard/app/components/AgentListModal.tsx b/packages/dashboard/app/components/AgentListModal.tsx index f495788b74..c7f3fb68c7 100644 --- a/packages/dashboard/app/components/AgentListModal.tsx +++ b/packages/dashboard/app/components/AgentListModal.tsx @@ -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 && (
{t("agents.workingOn", "Working on:")} - {agent.taskId} +
)} {agent.lastHeartbeatAt && ( diff --git a/packages/dashboard/app/components/AgentTaskBadge.tsx b/packages/dashboard/app/components/AgentTaskBadge.tsx new file mode 100644 index 0000000000..fd18b8f67d --- /dev/null +++ b/packages/dashboard/app/components/AgentTaskBadge.tsx @@ -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)}; +} diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index b19d6dcb86..2115bcc9e0 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -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 && (
{t("agents.workingOn", "Working on:")} - {agent.taskId} +
)}
diff --git a/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx b/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx index ef08090dd7..641b1aaafb 100644 --- a/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx @@ -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(); + const { container } = render(); - 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 () => { diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx index 59a1bdeac0..410d6d9819 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( { 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(); + + 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(); diff --git a/packages/dashboard/app/components/__tests__/AgentListModal.test.tsx b/packages/dashboard/app/components/__tests__/AgentListModal.test.tsx index 3373e9a3b2..c314251dac 100644 --- a/packages/dashboard/app/components/__tests__/AgentListModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentListModal.test.tsx @@ -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( { ); 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 () => { diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx index 19994cfd48..135bc969a7 100644 --- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx @@ -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(); + + 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(); await waitFor(() => { - expect(screen.getAllByText("FN-001").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText((_, el) => el?.textContent === "FN-001 · Unresolved task").length).toBeGreaterThanOrEqual(1); }); }); diff --git a/packages/dashboard/src/__tests__/routes-agents.test.ts b/packages/dashboard/src/__tests__/routes-agents.test.ts index 14dbd8e615..58a7cdbb85 100644 --- a/packages/dashboard/src/__tests__/routes-agents.test.ts +++ b/packages/dashboard/src/__tests__/routes-agents.test.ts @@ -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"); }); }); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 6e50a6eb66..0c80162935 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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 }; }); }