From bb0e66e7d3306c646960f8992a0716cf09fb6117 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:01:12 +0100 Subject: [PATCH 01/12] Add agent TypeScript types for profiles, runs, and events Defines AgentProfile, AgentRun, AgentRunDetail, AgentRunEvent interfaces matching the backend DTOs, plus helper maps for status labels and variants. --- frontend/taskdeck-web/src/types/agent.ts | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 frontend/taskdeck-web/src/types/agent.ts diff --git a/frontend/taskdeck-web/src/types/agent.ts b/frontend/taskdeck-web/src/types/agent.ts new file mode 100644 index 000000000..349782244 --- /dev/null +++ b/frontend/taskdeck-web/src/types/agent.ts @@ -0,0 +1,90 @@ +export type AgentScopeType = 'Workspace' | 'Board' + +export type AgentRunStatus = + | 'Queued' + | 'GatheringContext' + | 'Planning' + | 'ProposalCreated' + | 'WaitingForReview' + | 'Applying' + | 'Completed' + | 'Failed' + | 'Cancelled' + +export interface AgentProfile { + id: string + userId: string + name: string + description: string + templateKey: string + scopeType: AgentScopeType + scopeBoardId: string | null + policyJson: string + isEnabled: boolean + createdAt: string + updatedAt: string +} + +export interface AgentRun { + id: string + agentProfileId: string + userId: string + boardId: string | null + triggerType: string + objective: string + status: AgentRunStatus + summary: string | null + failureReason: string | null + proposalId: string | null + stepsExecuted: number + tokensUsed: number + approxCostUsd: number | null + startedAt: string + completedAt: string | null + createdAt: string + updatedAt: string +} + +export interface AgentRunEvent { + id: string + runId: string + sequenceNumber: number + eventType: string + payload: string + timestamp: string +} + +export interface AgentRunDetail extends AgentRun { + events: AgentRunEvent[] +} + +/** Human-readable labels for run statuses */ +export const runStatusLabels: Record = { + Queued: 'Queued', + GatheringContext: 'Gathering context', + Planning: 'Planning', + ProposalCreated: 'Proposal created', + WaitingForReview: 'Waiting for review', + Applying: 'Applying changes', + Completed: 'Completed', + Failed: 'Failed', + Cancelled: 'Cancelled', +} + +/** CSS modifier suffix for status badges */ +export const runStatusVariant: Record = { + Queued: 'neutral', + GatheringContext: 'info', + Planning: 'info', + ProposalCreated: 'warning', + WaitingForReview: 'warning', + Applying: 'info', + Completed: 'success', + Failed: 'error', + Cancelled: 'neutral', +} + +/** Whether a status is terminal (no further transitions expected) */ +export function isTerminalStatus(status: AgentRunStatus): boolean { + return status === 'Completed' || status === 'Failed' || status === 'Cancelled' +} From 868835ab85395b321c74abaf403394b1d2a07244 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:01:23 +0100 Subject: [PATCH 02/12] Add agentApi HTTP client for profiles and runs endpoints --- frontend/taskdeck-web/src/api/agentApi.ts | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 frontend/taskdeck-web/src/api/agentApi.ts diff --git a/frontend/taskdeck-web/src/api/agentApi.ts b/frontend/taskdeck-web/src/api/agentApi.ts new file mode 100644 index 000000000..39b6e2a0e --- /dev/null +++ b/frontend/taskdeck-web/src/api/agentApi.ts @@ -0,0 +1,28 @@ +import http from './http' +import type { AgentProfile, AgentRun, AgentRunDetail } from '../types/agent' + +export const agentApi = { + async listProfiles(): Promise { + const { data } = await http.get('/agents') + return data + }, + + async getProfile(id: string): Promise { + const { data } = await http.get(`/agents/${encodeURIComponent(id)}`) + return data + }, + + async listRuns(agentId: string, limit = 100): Promise { + const { data } = await http.get( + `/agents/${encodeURIComponent(agentId)}/runs?limit=${limit}`, + ) + return data + }, + + async getRunDetail(agentId: string, runId: string): Promise { + const { data } = await http.get( + `/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, + ) + return data + }, +} From 04e2fc651cd0828a575321cbda837eabd2c05bb9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:02:02 +0100 Subject: [PATCH 03/12] Add agentStore Pinia store for profiles, runs, and run detail --- frontend/taskdeck-web/src/store/agentStore.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 frontend/taskdeck-web/src/store/agentStore.ts diff --git a/frontend/taskdeck-web/src/store/agentStore.ts b/frontend/taskdeck-web/src/store/agentStore.ts new file mode 100644 index 000000000..1a599de2d --- /dev/null +++ b/frontend/taskdeck-web/src/store/agentStore.ts @@ -0,0 +1,116 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { agentApi } from '../api/agentApi' +import { useToastStore } from './toastStore' +import { isDemoMode } from '../utils/demoMode' +import { getErrorDisplay } from '../composables/useErrorMapper' +import type { AgentProfile, AgentRun, AgentRunDetail } from '../types/agent' + +export const useAgentStore = defineStore('agent', () => { + const toast = useToastStore() + + const profiles = ref([]) + const profilesLoading = ref(false) + const profilesError = ref(null) + + const runs = ref([]) + const runsLoading = ref(false) + const runsError = ref(null) + + const runDetail = ref(null) + const runDetailLoading = ref(false) + const runDetailError = ref(null) + + async function fetchProfiles(): Promise { + if (isDemoMode) { + profilesLoading.value = true + profilesError.value = null + profiles.value = [] + profilesLoading.value = false + return + } + try { + profilesLoading.value = true + profilesError.value = null + profiles.value = await agentApi.listProfiles() + } catch (e: unknown) { + const msg = getErrorDisplay(e, 'Failed to load agent profiles').message + profilesError.value = msg + toast.error(msg) + throw e + } finally { + profilesLoading.value = false + } + } + + async function fetchRuns(agentId: string, limit = 100): Promise { + if (isDemoMode) { + runsLoading.value = true + runsError.value = null + runs.value = [] + runsLoading.value = false + return + } + try { + runsLoading.value = true + runsError.value = null + runs.value = await agentApi.listRuns(agentId, limit) + } catch (e: unknown) { + const msg = getErrorDisplay(e, 'Failed to load agent runs').message + runsError.value = msg + toast.error(msg) + throw e + } finally { + runsLoading.value = false + } + } + + async function fetchRunDetail(agentId: string, runId: string): Promise { + if (isDemoMode) { + runDetailLoading.value = true + runDetailError.value = null + runDetail.value = null + runDetailLoading.value = false + return + } + try { + runDetailLoading.value = true + runDetailError.value = null + runDetail.value = await agentApi.getRunDetail(agentId, runId) + } catch (e: unknown) { + const msg = getErrorDisplay(e, 'Failed to load run details').message + runDetailError.value = msg + toast.error(msg) + throw e + } finally { + runDetailLoading.value = false + } + } + + function clearRuns(): void { + runs.value = [] + runsError.value = null + } + + function clearRunDetail(): void { + runDetail.value = null + runDetailError.value = null + } + + return { + profiles, + profilesLoading, + profilesError, + runs, + runsLoading, + runsError, + runDetail, + runDetailLoading, + runDetailError, + fetchProfiles, + fetchRuns, + fetchRunDetail, + clearRuns, + clearRunDetail, + } +}) From 59437126998608f6fec1a60b73d5a7555588fa1e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:02:52 +0100 Subject: [PATCH 04/12] Add AgentsView listing agent profiles with status badges --- .../taskdeck-web/src/views/AgentsView.vue | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 frontend/taskdeck-web/src/views/AgentsView.vue diff --git a/frontend/taskdeck-web/src/views/AgentsView.vue b/frontend/taskdeck-web/src/views/AgentsView.vue new file mode 100644 index 000000000..bb716466e --- /dev/null +++ b/frontend/taskdeck-web/src/views/AgentsView.vue @@ -0,0 +1,137 @@ + + + + + From 3d8387cc252bd338dde979c0dd7d84f28e37c4b3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:03:32 +0100 Subject: [PATCH 05/12] Add AgentRunsView listing runs for a selected agent profile --- .../taskdeck-web/src/views/AgentRunsView.vue | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 frontend/taskdeck-web/src/views/AgentRunsView.vue diff --git a/frontend/taskdeck-web/src/views/AgentRunsView.vue b/frontend/taskdeck-web/src/views/AgentRunsView.vue new file mode 100644 index 000000000..1c8448ac5 --- /dev/null +++ b/frontend/taskdeck-web/src/views/AgentRunsView.vue @@ -0,0 +1,200 @@ + + + + + From 0eef28988a310bfa54c0d28a0b6fe0357ea42c9e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:04:35 +0100 Subject: [PATCH 06/12] Add AgentRunDetailView with event timeline and proposal linkage --- .../src/views/AgentRunDetailView.vue | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 frontend/taskdeck-web/src/views/AgentRunDetailView.vue diff --git a/frontend/taskdeck-web/src/views/AgentRunDetailView.vue b/frontend/taskdeck-web/src/views/AgentRunDetailView.vue new file mode 100644 index 000000000..0064d768f --- /dev/null +++ b/frontend/taskdeck-web/src/views/AgentRunDetailView.vue @@ -0,0 +1,280 @@ + + + + + From bb1a4637a57328fa2e8878351980551bdc687f71 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:05:13 +0100 Subject: [PATCH 07/12] Add agent routes for profiles, runs, and run detail views --- frontend/taskdeck-web/src/router/index.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frontend/taskdeck-web/src/router/index.ts b/frontend/taskdeck-web/src/router/index.ts index 54d704b30..7eb6e3566 100644 --- a/frontend/taskdeck-web/src/router/index.ts +++ b/frontend/taskdeck-web/src/router/index.ts @@ -39,6 +39,9 @@ const ReviewView = () => import('../views/ReviewView.vue') const DevToolsView = () => import('../views/DevToolsView.vue') const SavedViewsView = () => import('../views/SavedViewsView.vue') const MetricsView = () => import('../views/MetricsView.vue') +const AgentsView = () => import('../views/AgentsView.vue') +const AgentRunsView = () => import('../views/AgentRunsView.vue') +const AgentRunDetailView = () => import('../views/AgentRunDetailView.vue') const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -257,6 +260,26 @@ const router = createRouter({ meta: { requiresShell: true }, }, + // Agent surfaces (visible in agent workspace mode) + { + path: '/workspace/agents', + name: 'workspace-agents', + component: AgentsView, + meta: { requiresShell: true }, + }, + { + path: '/workspace/agents/:agentId/runs', + name: 'workspace-agent-runs', + component: AgentRunsView, + meta: { requiresShell: true }, + }, + { + path: '/workspace/agents/:agentId/runs/:runId', + name: 'workspace-agent-run-detail', + component: AgentRunDetailView, + meta: { requiresShell: true }, + }, + // Internal dev tooling (trace replay + scenario editor) { path: '/workspace/dev-tools', From 2b43633adadf699f5c826d476c85d045a8002d77 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:05:39 +0100 Subject: [PATCH 08/12] Add Agents nav item to sidebar, primary in agent workspace mode --- .../taskdeck-web/src/components/shell/ShellSidebar.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue b/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue index d577b4bc9..c80983ab5 100644 --- a/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue +++ b/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue @@ -111,6 +111,15 @@ const navCatalog: NavItem[] = [ primaryModes: ['guided', 'workbench', 'agent'], keywords: 'inbox captures triage', }, + { + id: 'agents', + label: 'Agents', + icon: 'G', + path: '/workspace/agents', + flag: null, + primaryModes: ['agent'], + keywords: 'agents profiles runs automation agent mode', + }, { id: 'views', label: 'Views', From 72c394c52931810bbb9b42cf0c45f2ed451a053d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:07:34 +0100 Subject: [PATCH 09/12] Add agentStore unit tests for profiles, runs, and demo mode --- .../src/tests/store/agentStore.spec.ts | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/agentStore.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/agentStore.spec.ts b/frontend/taskdeck-web/src/tests/store/agentStore.spec.ts new file mode 100644 index 000000000..35b261de2 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/agentStore.spec.ts @@ -0,0 +1,311 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { agentApi } from '../../api/agentApi' +import { useAgentStore } from '../../store/agentStore' +import type { AgentProfile, AgentRun, AgentRunDetail } from '../../types/agent' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isDemoMode: false, + } +}) + +vi.mock('../../api/agentApi', () => ({ + agentApi: { + listProfiles: vi.fn(), + getProfile: vi.fn(), + listRuns: vi.fn(), + getRunDetail: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +const MOCK_PROFILE: AgentProfile = { + id: 'profile-1', + userId: 'user-1', + name: 'Test Agent', + description: 'A test agent', + templateKey: 'triage-assistant', + scopeType: 'Workspace', + scopeBoardId: null, + policyJson: '{}', + isEnabled: true, + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-01T00:00:00Z', +} + +const MOCK_RUN: AgentRun = { + id: 'run-1', + agentProfileId: 'profile-1', + userId: 'user-1', + boardId: null, + triggerType: 'manual', + objective: 'Triage inbox captures', + status: 'Completed', + summary: 'Triaged 5 captures', + failureReason: null, + proposalId: 'proposal-1', + stepsExecuted: 3, + tokensUsed: 1200, + approxCostUsd: 0.0012, + startedAt: '2026-04-01T10:00:00Z', + completedAt: '2026-04-01T10:01:00Z', + createdAt: '2026-04-01T10:00:00Z', + updatedAt: '2026-04-01T10:01:00Z', +} + +const MOCK_RUN_DETAIL: AgentRunDetail = { + ...MOCK_RUN, + events: [ + { + id: 'event-1', + runId: 'run-1', + sequenceNumber: 0, + eventType: 'run.started', + payload: '{}', + timestamp: '2026-04-01T10:00:00Z', + }, + { + id: 'event-2', + runId: 'run-1', + sequenceNumber: 1, + eventType: 'context.gathered', + payload: '{"captureCount": 5}', + timestamp: '2026-04-01T10:00:10Z', + }, + ], +} + +describe('agentStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('starts with empty default state', () => { + const store = useAgentStore() + expect(store.profiles).toEqual([]) + expect(store.profilesLoading).toBe(false) + expect(store.profilesError).toBeNull() + expect(store.runs).toEqual([]) + expect(store.runsLoading).toBe(false) + expect(store.runsError).toBeNull() + expect(store.runDetail).toBeNull() + expect(store.runDetailLoading).toBe(false) + expect(store.runDetailError).toBeNull() + }) + + describe('fetchProfiles', () => { + it('populates profiles from the API', async () => { + const store = useAgentStore() + vi.mocked(agentApi.listProfiles).mockResolvedValue([MOCK_PROFILE]) + + await store.fetchProfiles() + + expect(agentApi.listProfiles).toHaveBeenCalled() + expect(store.profiles).toEqual([MOCK_PROFILE]) + expect(store.profilesError).toBeNull() + expect(store.profilesLoading).toBe(false) + }) + + it('sets loading true during fetch and false after', async () => { + const store = useAgentStore() + let resolveRequest: ((value: AgentProfile[]) => void) | null = null + + vi.mocked(agentApi.listProfiles).mockImplementation( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + + const fetchPromise = store.fetchProfiles() + expect(store.profilesLoading).toBe(true) + + resolveRequest?.([]) + await fetchPromise + expect(store.profilesLoading).toBe(false) + }) + + it('records error and shows toast on failure', async () => { + const store = useAgentStore() + vi.mocked(agentApi.listProfiles).mockRejectedValue(new Error('Network error')) + + await expect(store.fetchProfiles()).rejects.toBeInstanceOf(Error) + + expect(store.profilesError).toBe('Network error') + expect(toastMocks.error).toHaveBeenCalledWith('Network error') + expect(store.profilesLoading).toBe(false) + }) + }) + + describe('fetchRuns', () => { + it('populates runs from the API', async () => { + const store = useAgentStore() + vi.mocked(agentApi.listRuns).mockResolvedValue([MOCK_RUN]) + + await store.fetchRuns('profile-1') + + expect(agentApi.listRuns).toHaveBeenCalledWith('profile-1', 100) + expect(store.runs).toEqual([MOCK_RUN]) + expect(store.runsError).toBeNull() + }) + + it('records error and shows toast on failure', async () => { + const store = useAgentStore() + vi.mocked(agentApi.listRuns).mockRejectedValue(new Error('Forbidden')) + + await expect(store.fetchRuns('profile-1')).rejects.toBeInstanceOf(Error) + + expect(store.runsError).toBe('Forbidden') + expect(toastMocks.error).toHaveBeenCalledWith('Forbidden') + }) + + it('sets loading true during fetch and false after', async () => { + const store = useAgentStore() + let resolveRequest: ((value: AgentRun[]) => void) | null = null + + vi.mocked(agentApi.listRuns).mockImplementation( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + + const fetchPromise = store.fetchRuns('profile-1') + expect(store.runsLoading).toBe(true) + + resolveRequest?.([]) + await fetchPromise + expect(store.runsLoading).toBe(false) + }) + }) + + describe('fetchRunDetail', () => { + it('populates runDetail from the API', async () => { + const store = useAgentStore() + vi.mocked(agentApi.getRunDetail).mockResolvedValue(MOCK_RUN_DETAIL) + + await store.fetchRunDetail('profile-1', 'run-1') + + expect(agentApi.getRunDetail).toHaveBeenCalledWith('profile-1', 'run-1') + expect(store.runDetail).toEqual(MOCK_RUN_DETAIL) + expect(store.runDetailError).toBeNull() + }) + + it('records error and shows toast on failure', async () => { + const store = useAgentStore() + vi.mocked(agentApi.getRunDetail).mockRejectedValue(new Error('Not found')) + + await expect(store.fetchRunDetail('profile-1', 'run-1')).rejects.toBeInstanceOf(Error) + + expect(store.runDetailError).toBe('Not found') + expect(toastMocks.error).toHaveBeenCalledWith('Not found') + }) + + it('sets loading true during fetch and false after', async () => { + const store = useAgentStore() + let resolveRequest: ((value: AgentRunDetail) => void) | null = null + + vi.mocked(agentApi.getRunDetail).mockImplementation( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + + const fetchPromise = store.fetchRunDetail('profile-1', 'run-1') + expect(store.runDetailLoading).toBe(true) + + resolveRequest?.(MOCK_RUN_DETAIL) + await fetchPromise + expect(store.runDetailLoading).toBe(false) + }) + }) + + describe('clearRuns', () => { + it('resets runs and error', async () => { + const store = useAgentStore() + vi.mocked(agentApi.listRuns).mockResolvedValue([MOCK_RUN]) + await store.fetchRuns('profile-1') + expect(store.runs).toHaveLength(1) + + store.clearRuns() + + expect(store.runs).toEqual([]) + expect(store.runsError).toBeNull() + }) + }) + + describe('clearRunDetail', () => { + it('resets runDetail and error', async () => { + const store = useAgentStore() + vi.mocked(agentApi.getRunDetail).mockResolvedValue(MOCK_RUN_DETAIL) + await store.fetchRunDetail('profile-1', 'run-1') + expect(store.runDetail).not.toBeNull() + + store.clearRunDetail() + + expect(store.runDetail).toBeNull() + expect(store.runDetailError).toBeNull() + }) + }) +}) + +describe('agentStore (demo mode)', () => { + beforeEach(async () => { + vi.resetModules() + vi.doMock('../../utils/demoMode', () => ({ isDemoMode: true })) + vi.doMock('../../api/agentApi', () => ({ + agentApi: { + listProfiles: vi.fn(), + getProfile: vi.fn(), + listRuns: vi.fn(), + getRunDetail: vi.fn(), + }, + })) + vi.doMock('../../store/toastStore', () => ({ + useToastStore: () => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }), + })) + setActivePinia(createPinia()) + }) + + it('fetchProfiles returns empty without calling API in demo mode', async () => { + const { useAgentStore: useDemoStore } = await import('../../store/agentStore') + const { agentApi: demoApi } = await import('../../api/agentApi') + const store = useDemoStore() + + await store.fetchProfiles() + + expect(demoApi.listProfiles).not.toHaveBeenCalled() + expect(store.profiles).toEqual([]) + expect(store.profilesLoading).toBe(false) + }) + + it('fetchRuns returns empty without calling API in demo mode', async () => { + const { useAgentStore: useDemoStore } = await import('../../store/agentStore') + const { agentApi: demoApi } = await import('../../api/agentApi') + const store = useDemoStore() + + await store.fetchRuns('profile-1') + + expect(demoApi.listRuns).not.toHaveBeenCalled() + expect(store.runs).toEqual([]) + expect(store.runsLoading).toBe(false) + }) + + it('fetchRunDetail returns null without calling API in demo mode', async () => { + const { useAgentStore: useDemoStore } = await import('../../store/agentStore') + const { agentApi: demoApi } = await import('../../api/agentApi') + const store = useDemoStore() + + await store.fetchRunDetail('profile-1', 'run-1') + + expect(demoApi.getRunDetail).not.toHaveBeenCalled() + expect(store.runDetail).toBeNull() + expect(store.runDetailLoading).toBe(false) + }) +}) From f9f6fcfc40be76e368a018a65bc5bd6010082a83 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:07:39 +0100 Subject: [PATCH 10/12] Add view tests for AgentsView, AgentRunsView, and AgentRunDetailView --- .../tests/views/AgentRunDetailView.spec.ts | 246 ++++++++++++++++++ .../src/tests/views/AgentRunsView.spec.ts | 149 +++++++++++ .../src/tests/views/AgentsView.spec.ts | 124 +++++++++ 3 files changed, 519 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts create mode 100644 frontend/taskdeck-web/src/tests/views/AgentRunsView.spec.ts create mode 100644 frontend/taskdeck-web/src/tests/views/AgentsView.spec.ts diff --git a/frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts b/frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts new file mode 100644 index 000000000..f36dcc9d6 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { reactive } from 'vue' +import AgentRunDetailView from '../../views/AgentRunDetailView.vue' +import type { AgentProfile, AgentRunDetail } from '../../types/agent' + +const mockPush = vi.fn() + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: mockPush }), + useRoute: () => ({ params: { agentId: 'profile-1', runId: 'run-1' } }), +})) + +const MOCK_PROFILE: AgentProfile = { + id: 'profile-1', + userId: 'user-1', + name: 'Triage Bot', + description: 'Triages captures', + templateKey: 'triage-assistant', + scopeType: 'Workspace', + scopeBoardId: null, + policyJson: '{}', + isEnabled: true, + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-01T00:00:00Z', +} + +const MOCK_RUN_DETAIL: AgentRunDetail = { + id: 'run-1', + agentProfileId: 'profile-1', + userId: 'user-1', + boardId: null, + triggerType: 'manual', + objective: 'Triage inbox captures', + status: 'Completed', + summary: 'Triaged 5 captures', + failureReason: null, + proposalId: 'proposal-1', + stepsExecuted: 3, + tokensUsed: 1200, + approxCostUsd: 0.0012, + startedAt: '2026-04-01T10:00:00Z', + completedAt: '2026-04-01T10:01:00Z', + createdAt: '2026-04-01T10:00:00Z', + updatedAt: '2026-04-01T10:01:00Z', + events: [ + { + id: 'event-1', + runId: 'run-1', + sequenceNumber: 0, + eventType: 'run.started', + payload: '{}', + timestamp: '2026-04-01T10:00:00Z', + }, + { + id: 'event-2', + runId: 'run-1', + sequenceNumber: 1, + eventType: 'context.gathered', + payload: '{"captureCount": 5}', + timestamp: '2026-04-01T10:00:10Z', + }, + { + id: 'event-3', + runId: 'run-1', + sequenceNumber: 2, + eventType: 'run.completed', + payload: '{}', + timestamp: '2026-04-01T10:01:00Z', + }, + ], +} + +const mockAgentStore = reactive({ + profiles: [MOCK_PROFILE] as AgentProfile[], + profilesLoading: false, + profilesError: null as string | null, + runDetail: null as AgentRunDetail | null, + runDetailLoading: false, + runDetailError: null as string | null, + fetchProfiles: vi.fn().mockResolvedValue(undefined), + fetchRunDetail: vi.fn().mockResolvedValue(undefined), + clearRunDetail: vi.fn(), +}) + +vi.mock('../../store/agentStore', () => ({ + useAgentStore: () => mockAgentStore, +})) + +async function waitForUi() { + await flushPromises() + await flushPromises() +} + +describe('AgentRunDetailView', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAgentStore.profiles = [MOCK_PROFILE] + mockAgentStore.runDetail = null + mockAgentStore.runDetailLoading = false + mockAgentStore.runDetailError = null + }) + + it('calls fetchRunDetail on mount', async () => { + mount(AgentRunDetailView) + await waitForUi() + + expect(mockAgentStore.fetchRunDetail).toHaveBeenCalledWith('profile-1', 'run-1') + }) + + it('shows loading state', async () => { + mockAgentStore.runDetailLoading = true + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + expect(wrapper.text()).toContain('Loading run detail...') + }) + + it('shows error state with retry button', async () => { + mockAgentStore.runDetailError = 'Not found' + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + expect(wrapper.text()).toContain('Not found') + expect(wrapper.find('.td-run-detail__state--error button').exists()).toBe(true) + }) + + it('renders run header with objective, status, and metadata', async () => { + mockAgentStore.runDetail = MOCK_RUN_DETAIL + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + expect(wrapper.text()).toContain('Triage inbox captures') + expect(wrapper.text()).toContain('Completed') + expect(wrapper.text()).toContain('Steps: 3') + expect(wrapper.text()).toContain('Tokens: 1,200') + expect(wrapper.text()).toContain('Triaged 5 captures') + }) + + it('renders timeline events in sequence order', async () => { + mockAgentStore.runDetail = MOCK_RUN_DETAIL + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + const items = wrapper.findAll('.td-timeline__item') + expect(items).toHaveLength(3) + expect(items[0].text()).toContain('Run started') + expect(items[0].text()).toContain('Step 1') + expect(items[1].text()).toContain('Context gathered') + expect(items[1].text()).toContain('Step 2') + expect(items[2].text()).toContain('Run completed') + expect(items[2].text()).toContain('Step 3') + }) + + it('renders event payload when non-empty', async () => { + mockAgentStore.runDetail = MOCK_RUN_DETAIL + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + const payloads = wrapper.findAll('.td-timeline__payload') + // Only event-2 has non-empty payload (captureCount: 5) + expect(payloads).toHaveLength(1) + expect(payloads[0].text()).toContain('captureCount') + }) + + it('shows proposal link when proposalId is present', async () => { + mockAgentStore.runDetail = MOCK_RUN_DETAIL + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + const proposalLink = wrapper.find('.td-run-detail__proposal-link') + expect(proposalLink.exists()).toBe(true) + expect(proposalLink.text()).toBe('View linked proposal') + }) + + it('navigates to proposal review when proposal link is clicked', async () => { + mockAgentStore.runDetail = MOCK_RUN_DETAIL + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + await wrapper.find('.td-run-detail__proposal-link').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/workspace/review?proposalId=proposal-1') + }) + + it('does not show proposal link when proposalId is null', async () => { + mockAgentStore.runDetail = { ...MOCK_RUN_DETAIL, proposalId: null } + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + expect(wrapper.find('.td-run-detail__proposal-link').exists()).toBe(false) + }) + + it('shows failure reason for failed runs', async () => { + mockAgentStore.runDetail = { + ...MOCK_RUN_DETAIL, + status: 'Failed', + failureReason: 'Provider timeout', + } + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + expect(wrapper.text()).toContain('Provider timeout') + }) + + it('does not show live indicator for terminal status', async () => { + mockAgentStore.runDetail = MOCK_RUN_DETAIL // status: Completed + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + expect(wrapper.find('.td-run-detail__live-indicator').exists()).toBe(false) + }) + + it('shows live indicator for non-terminal status', async () => { + mockAgentStore.runDetail = { ...MOCK_RUN_DETAIL, status: 'Planning' } + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + const indicator = wrapper.find('.td-run-detail__live-indicator') + expect(indicator.exists()).toBe(true) + expect(indicator.text()).toContain('Run is in progress') + }) + + it('navigates back to runs list when back button is clicked', async () => { + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + await wrapper.find('.td-run-detail__back').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/workspace/agents/profile-1/runs') + }) + + it('calls clearRunDetail on unmount', async () => { + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + wrapper.unmount() + expect(mockAgentStore.clearRunDetail).toHaveBeenCalled() + }) + + it('shows empty timeline when no events exist', async () => { + mockAgentStore.runDetail = { ...MOCK_RUN_DETAIL, events: [] } + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + expect(wrapper.text()).toContain('No events recorded') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/AgentRunsView.spec.ts b/frontend/taskdeck-web/src/tests/views/AgentRunsView.spec.ts new file mode 100644 index 000000000..559e7b93c --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/AgentRunsView.spec.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { reactive } from 'vue' +import AgentRunsView from '../../views/AgentRunsView.vue' +import type { AgentProfile, AgentRun } from '../../types/agent' + +const mockPush = vi.fn() + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: mockPush }), + useRoute: () => ({ params: { agentId: 'profile-1' } }), +})) + +const MOCK_PROFILE: AgentProfile = { + id: 'profile-1', + userId: 'user-1', + name: 'Triage Bot', + description: 'Triages captures', + templateKey: 'triage-assistant', + scopeType: 'Workspace', + scopeBoardId: null, + policyJson: '{}', + isEnabled: true, + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-01T00:00:00Z', +} + +const MOCK_RUN: AgentRun = { + id: 'run-1', + agentProfileId: 'profile-1', + userId: 'user-1', + boardId: null, + triggerType: 'manual', + objective: 'Triage inbox captures', + status: 'Completed', + summary: 'Triaged 5 captures', + failureReason: null, + proposalId: 'proposal-1', + stepsExecuted: 3, + tokensUsed: 1200, + approxCostUsd: 0.0012, + startedAt: '2026-04-01T10:00:00Z', + completedAt: '2026-04-01T10:01:00Z', + createdAt: '2026-04-01T10:00:00Z', + updatedAt: '2026-04-01T10:01:00Z', +} + +const mockAgentStore = reactive({ + profiles: [MOCK_PROFILE] as AgentProfile[], + profilesLoading: false, + profilesError: null as string | null, + runs: [] as AgentRun[], + runsLoading: false, + runsError: null as string | null, + fetchProfiles: vi.fn().mockResolvedValue(undefined), + fetchRuns: vi.fn().mockResolvedValue(undefined), + clearRuns: vi.fn(), +}) + +vi.mock('../../store/agentStore', () => ({ + useAgentStore: () => mockAgentStore, +})) + +async function waitForUi() { + await flushPromises() + await flushPromises() +} + +describe('AgentRunsView', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAgentStore.profiles = [MOCK_PROFILE] + mockAgentStore.runs = [] + mockAgentStore.runsLoading = false + mockAgentStore.runsError = null + }) + + it('renders header with agent name and calls fetchRuns on mount', async () => { + const wrapper = mount(AgentRunsView) + await waitForUi() + + expect(wrapper.text()).toContain('Triage Bot') + expect(mockAgentStore.fetchRuns).toHaveBeenCalledWith('profile-1') + }) + + it('shows loading state', async () => { + mockAgentStore.runsLoading = true + const wrapper = mount(AgentRunsView) + await waitForUi() + + expect(wrapper.text()).toContain('Loading runs...') + }) + + it('shows error state with retry button', async () => { + mockAgentStore.runsError = 'Something failed' + const wrapper = mount(AgentRunsView) + await waitForUi() + + expect(wrapper.text()).toContain('Something failed') + expect(wrapper.find('.td-agent-runs__state--error button').exists()).toBe(true) + }) + + it('shows empty state when no runs exist', async () => { + const wrapper = mount(AgentRunsView) + await waitForUi() + + expect(wrapper.text()).toContain('No runs yet') + }) + + it('renders run cards with objective and status', async () => { + mockAgentStore.runs = [MOCK_RUN] + const wrapper = mount(AgentRunsView) + await waitForUi() + + expect(wrapper.text()).toContain('Triage inbox captures') + expect(wrapper.text()).toContain('Completed') + expect(wrapper.text()).toContain('Triaged 5 captures') + expect(wrapper.text()).toContain('Proposal linked') + }) + + it('shows failure reason for failed runs', async () => { + mockAgentStore.runs = [{ + ...MOCK_RUN, + status: 'Failed', + failureReason: 'Rate limit exceeded', + }] + const wrapper = mount(AgentRunsView) + await waitForUi() + + expect(wrapper.text()).toContain('Rate limit exceeded') + }) + + it('navigates to run detail when card is clicked', async () => { + mockAgentStore.runs = [MOCK_RUN] + const wrapper = mount(AgentRunsView) + await waitForUi() + + await wrapper.find('.td-agent-runs__card-btn').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/workspace/agents/profile-1/runs/run-1') + }) + + it('navigates back to agents list when back button is clicked', async () => { + const wrapper = mount(AgentRunsView) + await waitForUi() + + await wrapper.find('.td-agent-runs__back').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/workspace/agents') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/AgentsView.spec.ts b/frontend/taskdeck-web/src/tests/views/AgentsView.spec.ts new file mode 100644 index 000000000..773fa5b39 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/AgentsView.spec.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { reactive } from 'vue' +import AgentsView from '../../views/AgentsView.vue' +import type { AgentProfile } from '../../types/agent' + +const mockPush = vi.fn() + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: mockPush }), + useRoute: () => ({ params: {} }), +})) + +const mockAgentStore = reactive({ + profiles: [] as AgentProfile[], + profilesLoading: false, + profilesError: null as string | null, + fetchProfiles: vi.fn().mockResolvedValue(undefined), +}) + +vi.mock('../../store/agentStore', () => ({ + useAgentStore: () => mockAgentStore, +})) + +const MOCK_PROFILE: AgentProfile = { + id: 'profile-1', + userId: 'user-1', + name: 'Triage Bot', + description: 'Triages incoming captures', + templateKey: 'triage-assistant', + scopeType: 'Workspace', + scopeBoardId: null, + policyJson: '{}', + isEnabled: true, + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-01T00:00:00Z', +} + +async function waitForUi() { + await flushPromises() + await flushPromises() +} + +describe('AgentsView', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAgentStore.profiles = [] + mockAgentStore.profilesLoading = false + mockAgentStore.profilesError = null + }) + + it('renders header and calls fetchProfiles on mount', async () => { + const wrapper = mount(AgentsView) + await waitForUi() + + expect(wrapper.text()).toContain('Agents') + expect(mockAgentStore.fetchProfiles).toHaveBeenCalled() + }) + + it('shows loading state when profilesLoading is true', async () => { + mockAgentStore.profilesLoading = true + const wrapper = mount(AgentsView) + await waitForUi() + + expect(wrapper.text()).toContain('Loading agents...') + expect(wrapper.find('.td-agents__spinner').exists()).toBe(true) + }) + + it('shows error state with retry button', async () => { + mockAgentStore.profilesError = 'Failed to fetch' + const wrapper = mount(AgentsView) + await waitForUi() + + expect(wrapper.text()).toContain('Failed to fetch') + const retryBtn = wrapper.find('.td-agents__state--error button') + expect(retryBtn.exists()).toBe(true) + expect(retryBtn.text()).toBe('Retry') + }) + + it('shows empty state when no profiles exist', async () => { + const wrapper = mount(AgentsView) + await waitForUi() + + expect(wrapper.text()).toContain('No agents configured') + }) + + it('renders profile cards with name, status, and metadata', async () => { + mockAgentStore.profiles = [MOCK_PROFILE] + const wrapper = mount(AgentsView) + await waitForUi() + + expect(wrapper.text()).toContain('Triage Bot') + expect(wrapper.text()).toContain('Active') + expect(wrapper.text()).toContain('Workspace') + expect(wrapper.text()).toContain('triage-assistant') + }) + + it('shows Disabled badge for disabled profiles', async () => { + mockAgentStore.profiles = [{ ...MOCK_PROFILE, isEnabled: false }] + const wrapper = mount(AgentsView) + await waitForUi() + + expect(wrapper.text()).toContain('Disabled') + expect(wrapper.find('.td-agents__status-badge--disabled').exists()).toBe(true) + }) + + it('navigates to runs view when profile card is clicked', async () => { + mockAgentStore.profiles = [MOCK_PROFILE] + const wrapper = mount(AgentsView) + await waitForUi() + + await wrapper.find('.td-agents__card-btn').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/workspace/agents/profile-1/runs') + }) + + it('profile cards have accessible labels', async () => { + mockAgentStore.profiles = [MOCK_PROFILE] + const wrapper = mount(AgentsView) + await waitForUi() + + const btn = wrapper.find('.td-agents__card-btn') + expect(btn.attributes('aria-label')).toBe('View runs for Triage Bot') + }) +}) From 5d101863d082b96f435926b0bd9a0f6af8f062dc Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:11:42 +0100 Subject: [PATCH 11/12] Add enum normalizers for backend numeric enum serialization Backend serializes AgentScopeType and AgentRunStatus as integers. Add normalizeScopeType and normalizeRunStatus functions matching the established pattern (ops.ts, archive.ts) and apply them in agentApi. --- frontend/taskdeck-web/src/api/agentApi.ts | 60 +++++++++++++++++++---- frontend/taskdeck-web/src/types/agent.ts | 38 ++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/frontend/taskdeck-web/src/api/agentApi.ts b/frontend/taskdeck-web/src/api/agentApi.ts index 39b6e2a0e..3fbae91ad 100644 --- a/frontend/taskdeck-web/src/api/agentApi.ts +++ b/frontend/taskdeck-web/src/api/agentApi.ts @@ -1,28 +1,70 @@ import http from './http' -import type { AgentProfile, AgentRun, AgentRunDetail } from '../types/agent' +import { normalizeRunStatus, normalizeScopeType } from '../types/agent' +import type { + AgentProfile, + AgentRun, + AgentRunDetail, + AgentRunStatusValue, + AgentScopeTypeValue, +} from '../types/agent' + +/** Raw profile shape from backend (enums may be numeric) */ +interface RawAgentProfile extends Omit { + scopeType: AgentScopeTypeValue +} + +/** Raw run shape from backend (enums may be numeric) */ +interface RawAgentRun extends Omit { + status: AgentRunStatusValue +} + +interface RawAgentRunDetail extends Omit { + status: AgentRunStatusValue +} + +function normalizeProfile(raw: RawAgentProfile): AgentProfile { + return { + ...raw, + scopeType: normalizeScopeType(raw.scopeType), + } +} + +function normalizeRun(raw: RawAgentRun): AgentRun { + return { + ...raw, + status: normalizeRunStatus(raw.status), + } +} + +function normalizeRunDetail(raw: RawAgentRunDetail): AgentRunDetail { + return { + ...raw, + status: normalizeRunStatus(raw.status), + } +} export const agentApi = { async listProfiles(): Promise { - const { data } = await http.get('/agents') - return data + const { data } = await http.get('/agents') + return data.map(normalizeProfile) }, async getProfile(id: string): Promise { - const { data } = await http.get(`/agents/${encodeURIComponent(id)}`) - return data + const { data } = await http.get(`/agents/${encodeURIComponent(id)}`) + return normalizeProfile(data) }, async listRuns(agentId: string, limit = 100): Promise { - const { data } = await http.get( + const { data } = await http.get( `/agents/${encodeURIComponent(agentId)}/runs?limit=${limit}`, ) - return data + return data.map(normalizeRun) }, async getRunDetail(agentId: string, runId: string): Promise { - const { data } = await http.get( + const { data } = await http.get( `/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, ) - return data + return normalizeRunDetail(data) }, } diff --git a/frontend/taskdeck-web/src/types/agent.ts b/frontend/taskdeck-web/src/types/agent.ts index 349782244..25594c376 100644 --- a/frontend/taskdeck-web/src/types/agent.ts +++ b/frontend/taskdeck-web/src/types/agent.ts @@ -1,5 +1,8 @@ export type AgentScopeType = 'Workspace' | 'Board' +/** Raw value from backend -- may be a numeric enum index or a string */ +export type AgentScopeTypeValue = AgentScopeType | number + export type AgentRunStatus = | 'Queued' | 'GatheringContext' @@ -11,6 +14,41 @@ export type AgentRunStatus = | 'Failed' | 'Cancelled' +/** Raw value from backend -- may be a numeric enum index or a string */ +export type AgentRunStatusValue = AgentRunStatus | number + +const scopeTypeByIndex: readonly AgentScopeType[] = ['Workspace', 'Board'] as const + +const runStatusByIndex: readonly AgentRunStatus[] = [ + 'Queued', + 'GatheringContext', + 'Planning', + 'ProposalCreated', + 'WaitingForReview', + 'Applying', + 'Completed', + 'Failed', + 'Cancelled', +] as const + +/** Normalize a scope type from the backend (may arrive as number or string) */ +export function normalizeScopeType(value: AgentScopeTypeValue): AgentScopeType { + if (typeof value === 'number') { + return scopeTypeByIndex[value] ?? 'Workspace' + } + const found = scopeTypeByIndex.find((v) => v.toLowerCase() === value.toLowerCase()) + return found ?? 'Workspace' +} + +/** Normalize a run status from the backend (may arrive as number or string) */ +export function normalizeRunStatus(value: AgentRunStatusValue): AgentRunStatus { + if (typeof value === 'number') { + return runStatusByIndex[value] ?? 'Queued' + } + const found = runStatusByIndex.find((v) => v.toLowerCase() === value.toLowerCase()) + return found ?? 'Queued' +} + export interface AgentProfile { id: string userId: string From ac12f16931c86ac1b061c6dfb029ab6f01bacbf0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:52:30 +0100 Subject: [PATCH 12/12] Refine agent run detail view behavior --- .../src/tests/utils/agent.spec.ts | 56 +++++++++++++ .../tests/views/AgentRunDetailView.spec.ts | 33 ++++++-- .../src/views/AgentRunDetailView.vue | 80 +++++++++++++------ 3 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 frontend/taskdeck-web/src/tests/utils/agent.spec.ts diff --git a/frontend/taskdeck-web/src/tests/utils/agent.spec.ts b/frontend/taskdeck-web/src/tests/utils/agent.spec.ts new file mode 100644 index 000000000..dd661d7fc --- /dev/null +++ b/frontend/taskdeck-web/src/tests/utils/agent.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + isTerminalStatus, + normalizeRunStatus, + normalizeScopeType, +} from '../../types/agent' + +describe('normalizeScopeType', () => { + it('maps numeric enum ordinals from the backend', () => { + expect(normalizeScopeType(0)).toBe('Workspace') + expect(normalizeScopeType(1)).toBe('Board') + }) + + it('normalizes case-insensitive string values', () => { + expect(normalizeScopeType('workspace' as any)).toBe('Workspace') + expect(normalizeScopeType('BOARD' as any)).toBe('Board') + }) + + it('falls back to Workspace for unknown values', () => { + expect(normalizeScopeType(99 as any)).toBe('Workspace') + expect(normalizeScopeType(-1 as any)).toBe('Workspace') + expect(normalizeScopeType('unknown' as any)).toBe('Workspace') + }) +}) + +describe('normalizeRunStatus', () => { + it('maps numeric enum ordinals from the backend', () => { + expect(normalizeRunStatus(0)).toBe('Queued') + expect(normalizeRunStatus(1)).toBe('GatheringContext') + expect(normalizeRunStatus(6)).toBe('Completed') + }) + + it('normalizes case-insensitive string values', () => { + expect(normalizeRunStatus('completed' as any)).toBe('Completed') + expect(normalizeRunStatus('FAILED' as any)).toBe('Failed') + }) + + it('falls back to Queued for unknown values', () => { + expect(normalizeRunStatus(99 as any)).toBe('Queued') + expect(normalizeRunStatus(-1 as any)).toBe('Queued') + expect(normalizeRunStatus('unknown' as any)).toBe('Queued') + }) +}) + +describe('isTerminalStatus', () => { + it('recognizes terminal statuses', () => { + expect(isTerminalStatus('Completed')).toBe(true) + expect(isTerminalStatus('Failed')).toBe(true) + expect(isTerminalStatus('Cancelled')).toBe(true) + }) + + it('treats active statuses as non-terminal', () => { + expect(isTerminalStatus('Queued')).toBe(false) + expect(isTerminalStatus('Planning')).toBe(false) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts b/frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts index f36dcc9d6..86ea69e36 100644 --- a/frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts @@ -5,10 +5,14 @@ import AgentRunDetailView from '../../views/AgentRunDetailView.vue' import type { AgentProfile, AgentRunDetail } from '../../types/agent' const mockPush = vi.fn() +const routeParams = reactive({ + agentId: 'profile-1', + runId: 'run-1', +}) vi.mock('vue-router', () => ({ useRouter: () => ({ push: mockPush }), - useRoute: () => ({ params: { agentId: 'profile-1', runId: 'run-1' } }), + useRoute: () => ({ params: routeParams }), })) const MOCK_PROFILE: AgentProfile = { @@ -95,6 +99,8 @@ async function waitForUi() { describe('AgentRunDetailView', () => { beforeEach(() => { vi.clearAllMocks() + routeParams.agentId = 'profile-1' + routeParams.runId = 'run-1' mockAgentStore.profiles = [MOCK_PROFILE] mockAgentStore.runDetail = null mockAgentStore.runDetailLoading = false @@ -145,11 +151,11 @@ describe('AgentRunDetailView', () => { const items = wrapper.findAll('.td-timeline__item') expect(items).toHaveLength(3) expect(items[0].text()).toContain('Run started') - expect(items[0].text()).toContain('Step 1') + expect(items[0].text()).toContain('Sequence 1') expect(items[1].text()).toContain('Context gathered') - expect(items[1].text()).toContain('Step 2') + expect(items[1].text()).toContain('Sequence 2') expect(items[2].text()).toContain('Run completed') - expect(items[2].text()).toContain('Step 3') + expect(items[2].text()).toContain('Sequence 3') }) it('renders event payload when non-empty', async () => { @@ -179,7 +185,10 @@ describe('AgentRunDetailView', () => { await waitForUi() await wrapper.find('.td-run-detail__proposal-link').trigger('click') - expect(mockPush).toHaveBeenCalledWith('/workspace/review?proposalId=proposal-1') + expect(mockPush).toHaveBeenCalledWith({ + path: '/workspace/review', + query: { proposalId: 'proposal-1' }, + }) }) it('does not show proposal link when proposalId is null', async () => { @@ -236,6 +245,20 @@ describe('AgentRunDetailView', () => { expect(mockAgentStore.clearRunDetail).toHaveBeenCalled() }) + it('refetches run detail when route params change', async () => { + const wrapper = mount(AgentRunDetailView) + await waitForUi() + + mockAgentStore.fetchRunDetail.mockClear() + routeParams.runId = 'run-2' + await waitForUi() + + expect(mockAgentStore.clearRunDetail).toHaveBeenCalled() + expect(mockAgentStore.fetchRunDetail).toHaveBeenCalledWith('profile-1', 'run-2') + + wrapper.unmount() + }) + it('shows empty timeline when no events exist', async () => { mockAgentStore.runDetail = { ...MOCK_RUN_DETAIL, events: [] } const wrapper = mount(AgentRunDetailView) diff --git a/frontend/taskdeck-web/src/views/AgentRunDetailView.vue b/frontend/taskdeck-web/src/views/AgentRunDetailView.vue index 0064d768f..6eda9356b 100644 --- a/frontend/taskdeck-web/src/views/AgentRunDetailView.vue +++ b/frontend/taskdeck-web/src/views/AgentRunDetailView.vue @@ -1,5 +1,5 @@