diff --git a/frontend/taskdeck-web/src/api/agentApi.ts b/frontend/taskdeck-web/src/api/agentApi.ts new file mode 100644 index 000000000..3fbae91ad --- /dev/null +++ b/frontend/taskdeck-web/src/api/agentApi.ts @@ -0,0 +1,70 @@ +import http from './http' +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.map(normalizeProfile) + }, + + async getProfile(id: string): Promise { + const { data } = await http.get(`/agents/${encodeURIComponent(id)}`) + return normalizeProfile(data) + }, + + async listRuns(agentId: string, limit = 100): Promise { + const { data } = await http.get( + `/agents/${encodeURIComponent(agentId)}/runs?limit=${limit}`, + ) + return data.map(normalizeRun) + }, + + async getRunDetail(agentId: string, runId: string): Promise { + const { data } = await http.get( + `/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, + ) + return normalizeRunDetail(data) + }, +} 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', 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', 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, + } +}) 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) + }) +}) 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 new file mode 100644 index 000000000..86ea69e36 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/AgentRunDetailView.spec.ts @@ -0,0 +1,269 @@ +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() +const routeParams = reactive({ + agentId: 'profile-1', + runId: 'run-1', +}) + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: mockPush }), + useRoute: () => ({ params: routeParams }), +})) + +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() + routeParams.agentId = 'profile-1' + routeParams.runId = 'run-1' + 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('Sequence 1') + expect(items[1].text()).toContain('Context gathered') + expect(items[1].text()).toContain('Sequence 2') + expect(items[2].text()).toContain('Run completed') + expect(items[2].text()).toContain('Sequence 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({ + path: '/workspace/review', + query: { 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('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) + 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') + }) +}) diff --git a/frontend/taskdeck-web/src/types/agent.ts b/frontend/taskdeck-web/src/types/agent.ts new file mode 100644 index 000000000..25594c376 --- /dev/null +++ b/frontend/taskdeck-web/src/types/agent.ts @@ -0,0 +1,128 @@ +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' + | 'Planning' + | 'ProposalCreated' + | 'WaitingForReview' + | 'Applying' + | 'Completed' + | '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 + 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' +} diff --git a/frontend/taskdeck-web/src/views/AgentRunDetailView.vue b/frontend/taskdeck-web/src/views/AgentRunDetailView.vue new file mode 100644 index 000000000..6eda9356b --- /dev/null +++ b/frontend/taskdeck-web/src/views/AgentRunDetailView.vue @@ -0,0 +1,314 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + +