diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts index ec682be..d04c3fb 100644 --- a/frontend/src/__tests__/api/tasksApi.test.ts +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -229,6 +229,7 @@ TASK DETAILS INSTRUCTIONS - Write exactly one email message. +- The message must be written by Milton Fiscal (Director of Ceremonial Logistics) as the sender persona. - Use an extremely formal corporate tone. - Keep the content casual, mundane, and slightly nonsensical. - Reflect the entity personality and task prompt faithfully. diff --git a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx index 5c94f72..eba692b 100644 --- a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx @@ -39,11 +39,6 @@ const mockEntity = { describe('CreateTaskPage', () => { beforeEach(() => { - vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week') - vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation( - (entity, task) => - `PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback}` - ) vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) mockNavigate.mockClear() }) @@ -54,25 +49,36 @@ describe('CreateTaskPage', () => { await screen.findByRole('link', { name: /back to entity a/i }) expect(screen.getByLabelText(/task name/i)).toBeInTheDocument() - expect(screen.getByLabelText(/task prompt/i)).toBeInTheDocument() + expect(screen.queryByLabelText(/task prompt/i)).not.toBeInTheDocument() expect(screen.getByLabelText(/^Email Period$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Minute$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Hour$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Day of Month$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Month$/i)).toBeInTheDocument() expect(screen.getByLabelText(/^Day of Week$/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /generate test message/i })).not.toBeInTheDocument() + expect(screen.queryByText(/Final Prompt/i)).not.toBeInTheDocument() }) - it('should_generatePreviewAndCreateTask_when_formSubmitted', async () => { - vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense') + it('should_createInactiveTaskAndNavigateToEditTask_when_formSubmitted', async () => { vi.mocked(tasksApi.createTask).mockResolvedValue({ id: 'task-2', entityId: 'entity-1', name: 'Morning Blast', - prompt: 'Talk about coffee', + prompt: '', scheduleCron: '0 8 * * 1-5', emailLookback: 'last_week', - active: true, + active: false, + createdAt: '2026-03-26T10:00:00Z', + }) + vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ + id: 'task-2', + entityId: 'entity-1', + name: 'Morning Blast', + prompt: '', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_week', + active: false, createdAt: '2026-03-26T10:00:00Z', }) @@ -97,45 +103,10 @@ describe('CreateTaskPage', () => { expect(screen.getByRole('button', { name: /Monthly/i })).toBeInTheDocument() fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } }) - fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } }) fireEvent.click(screen.getByRole('button', { name: /Weekdays/i })) fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } }) - expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument() - expect( - screen.getByText('PROMPT FOR Entity A: Morning Blast | Talk about coffee | 0 8 * * 1-5 | last_week') - ).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) - - await waitFor(() => { - expect(tasksApi.generateTaskPreview).toHaveBeenCalled() - expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith( - mockEntity, - expect.objectContaining({ - entityId: 'entity-1', - name: 'Morning Blast', - prompt: 'Talk about coffee', - scheduleCron: '0 8 * * 1-5', - emailLookback: 'last_week', - }) - ) - expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual( - expect.objectContaining({ - entity: mockEntity, - task: expect.objectContaining({ - entityId: 'entity-1', - name: 'Morning Blast', - prompt: 'Talk about coffee', - scheduleCron: '0 8 * * 1-5', - emailLookback: 'last_week', - }), - }) - ) - expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument() - }) - fireEvent.click(screen.getByRole('button', { name: /create task/i })) await waitFor(() => { @@ -144,32 +115,13 @@ describe('CreateTaskPage', () => { expect.objectContaining({ entityId: 'entity-1', name: 'Morning Blast', - prompt: 'Talk about coffee', + prompt: '', scheduleCron: '0 8 * * 1-5', emailLookback: 'last_week', }) ) - expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1') - }) - }) - - it('should_showReadablePreviewError_when_generationFails', async () => { - vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue( - new Error('Unable to generate a test message from the local model. Connection refused') - ) - - render(, { wrapper }) - await screen.findByRole('link', { name: /back to entity a/i }) - - fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } }) - fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } }) - - fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) - - await waitFor(() => { - expect( - screen.getByText(/Unable to generate a test message from the local model. Connection refused/i) - ).toBeInTheDocument() + expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-2') + expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1/tasks/task-2') }) }) diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx index 5462e79..f1c36a7 100644 --- a/frontend/src/__tests__/pages/EditTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -186,6 +186,59 @@ describe('EditTaskPage', () => { }) }) + it('should_renderGeneratedMessagePanels_when_pageLoads', async () => { + renderPage() + + await screen.findByRole('link', { name: /back to entity a/i }) + + expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument() + expect(screen.getByText(/^Generated Message$/i)).toBeInTheDocument() + expect(screen.getByText(/^Generated Message History$/i)).toBeInTheDocument() + expect(screen.getByText(/Generate a message and it will appear here./i)).toBeInTheDocument() + expect(screen.getByRole('list', { name: /generated message history/i })).toBeInTheDocument() + expect(screen.getByText(/No generated messages yet./i)).toBeInTheDocument() + }) + + it('should_showAndManageGeneratedMessageHistory_when_multipleMessagesGenerated', async () => { + vi.mocked(tasksApi.generateTaskPreview) + .mockResolvedValueOnce('SUBJECT: First\nBODY:\nFirst output') + .mockResolvedValueOnce('SUBJECT: Second\nBODY:\nSecond output') + + renderPage() + await screen.findByRole('link', { name: /back to entity a/i }) + + const generateButton = screen.getByRole('button', { name: /generate test message/i }) + + fireEvent.click(generateButton) + await screen.findByText(/First output/i) + + fireEvent.click(generateButton) + + await waitFor(() => { + expect(screen.getByText(/Second output/i)).toBeInTheDocument() + }) + + const history = screen.getByRole('list', { name: /generated message history/i }) + expect(history).toBeInTheDocument() + + const firstMessageHistoryItem = screen.getByRole('button', { name: /^message #1$/i }) + const secondMessageHistoryItem = screen.getByRole('button', { name: /^message #2$/i }) + + fireEvent.click(firstMessageHistoryItem) + expect(screen.getByText(/First output/i)).toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { + name: /delete message #1/i, + }) + ) + + await waitFor(() => { + expect(firstMessageHistoryItem).not.toBeInTheDocument() + expect(secondMessageHistoryItem).toBeInTheDocument() + }) + }) + it('should_renderActivateButton_when_taskIsInactive', async () => { renderPage({ task: { ...mockTask, active: false } }) diff --git a/frontend/src/__tests__/pages/EntitiesPage.test.tsx b/frontend/src/__tests__/pages/EntitiesPage.test.tsx index 1321703..9167270 100644 --- a/frontend/src/__tests__/pages/EntitiesPage.test.tsx +++ b/frontend/src/__tests__/pages/EntitiesPage.test.tsx @@ -41,6 +41,37 @@ describe('EntitiesPage', () => { fireEvent.click(addButton) await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.queryByLabelText(/default email context window/i)).not.toBeInTheDocument() + }) + }) + + it('should_submitDefaultContextWindow_when_createEntitySubmitted', async () => { + vi.mocked(entitiesApi.getEntities).mockResolvedValue([]) + vi.mocked(entitiesApi.createEntity).mockResolvedValue(mockEntity) + + render(, { wrapper }) + + fireEvent.click(screen.getByRole('button', { name: /add|create|new/i })) + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: /create entity/i })).toBeInTheDocument() + }) + + fireEvent.change(screen.getByLabelText(/entity name/i), { target: { value: 'Test Entity' } }) + fireEvent.change(screen.getByLabelText(/sender email/i), { target: { value: 'test@condado.com' } }) + fireEvent.change(screen.getByLabelText(/job title/i), { target: { value: 'Tester' } }) + fireEvent.change(screen.getByLabelText(/personality notes/i), { target: { value: 'Formal' } }) + fireEvent.click(screen.getByRole('button', { name: /create/i })) + + await waitFor(() => { + expect(entitiesApi.createEntity).toHaveBeenCalled() + expect(vi.mocked(entitiesApi.createEntity).mock.calls[0]?.[0]).toEqual({ + name: 'Test Entity', + email: 'test@condado.com', + jobTitle: 'Tester', + personality: 'Formal', + contextWindowDays: 3, + }) }) }) diff --git a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx index 4bf02c5..3038d2e 100644 --- a/frontend/src/__tests__/pages/EntityDetailPage.test.tsx +++ b/frontend/src/__tests__/pages/EntityDetailPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -20,6 +20,84 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( ) describe('EntityDetailPage', () => { + it('should_updateEntity_when_editSubmittedFromDetailPage', async () => { + vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week') + + vi.mocked(entitiesApi.getEntity) + .mockResolvedValueOnce({ + id: 'entity-1', + name: 'Entity A', + email: 'a@a.com', + jobTitle: 'Ops', + personality: 'Formal', + scheduleCron: '0 9 * * 1', + contextWindowDays: 3, + active: true, + createdAt: '', + }) + .mockResolvedValueOnce({ + id: 'entity-1', + name: 'Entity A Updated', + email: 'updated@a.com', + jobTitle: 'Operations Lead', + personality: 'Still formal', + scheduleCron: '0 9 * * 1', + contextWindowDays: 5, + active: true, + createdAt: '', + }) + + vi.mocked(entitiesApi.updateEntity).mockResolvedValue({ + id: 'entity-1', + name: 'Entity A Updated', + email: 'updated@a.com', + jobTitle: 'Operations Lead', + personality: 'Still formal', + scheduleCron: '0 9 * * 1', + contextWindowDays: 5, + active: true, + createdAt: '', + }) + vi.mocked(tasksApi.getTasksByEntity).mockResolvedValue([]) + + render(, { wrapper }) + + await waitFor(() => { + expect(screen.getByText(/Entity A/i)).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /edit entity/i })) + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: /edit entity/i })).toBeInTheDocument() + expect(screen.getByLabelText(/entity name/i)).toHaveValue('Entity A') + expect(screen.getByLabelText(/sender email/i)).toHaveValue('a@a.com') + expect(screen.getByLabelText(/job title/i)).toHaveValue('Ops') + expect(screen.getByLabelText(/personality notes/i)).toHaveValue('Formal') + expect(screen.queryByLabelText(/default email context window/i)).not.toBeInTheDocument() + }) + + fireEvent.change(screen.getByLabelText(/entity name/i), { target: { value: 'Entity A Updated' } }) + fireEvent.change(screen.getByLabelText(/sender email/i), { target: { value: 'updated@a.com' } }) + fireEvent.change(screen.getByLabelText(/job title/i), { target: { value: 'Operations Lead' } }) + fireEvent.change(screen.getByLabelText(/personality notes/i), { target: { value: 'Still formal' } }) + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + await waitFor(() => { + expect(entitiesApi.updateEntity).toHaveBeenCalledWith('entity-1', { + name: 'Entity A Updated', + email: 'updated@a.com', + jobTitle: 'Operations Lead', + personality: 'Still formal', + contextWindowDays: 3, + }) + expect(screen.queryByRole('dialog', { name: /edit entity/i })).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: /Entity A Updated/i })).toBeInTheDocument() + expect(screen.getByText(/Operations Lead - updated@a.com/i)).toBeInTheDocument() + }) + }) + it('should_renderEntityAndTasks_when_pageLoads', async () => { vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week') diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index 1666bc2..df1c3b5 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -108,6 +108,7 @@ export function buildTaskPreviewPrompt( '', 'INSTRUCTIONS', '- Write exactly one email message.', + `- The message must be written by ${entity.name} (${entity.jobTitle}) as the sender persona.`, '- Use an extremely formal corporate tone.', '- Keep the content casual, mundane, and slightly nonsensical.', '- Reflect the entity personality and task prompt faithfully.', diff --git a/frontend/src/pages/CreateTaskPage.tsx b/frontend/src/pages/CreateTaskPage.tsx index e00341a..16c64d9 100644 --- a/frontend/src/pages/CreateTaskPage.tsx +++ b/frontend/src/pages/CreateTaskPage.tsx @@ -3,15 +3,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useNavigate, useParams } from 'react-router-dom' import { getEntity } from '../api/entitiesApi' import { - buildTaskPreviewPrompt, createTask, - generateTaskPreview, + inactivateTask, type EmailLookback, } from '../api/tasksApi' interface TaskFormState { name: string - prompt: string scheduleCron: string emailLookback: EmailLookback } @@ -72,7 +70,6 @@ function buildCron(parts: CronParts): string { const DEFAULT_TASK_FORM: TaskFormState = { name: '', - prompt: '', scheduleCron: buildCron(DEFAULT_CRON_PARTS), emailLookback: 'last_week', } @@ -83,8 +80,6 @@ export default function CreateTaskPage() { const queryClient = useQueryClient() const [cronParts, setCronParts] = useState(DEFAULT_CRON_PARTS) const [taskForm, setTaskForm] = useState(DEFAULT_TASK_FORM) - const [preview, setPreview] = useState('') - const [previewError, setPreviewError] = useState('') const { data: entity, isLoading } = useQuery({ queryKey: ['entity', entityId], @@ -94,51 +89,18 @@ export default function CreateTaskPage() { const createTaskMutation = useMutation({ mutationFn: createTask, - onSuccess: async () => { + onSuccess: async (createdTask) => { + await inactivateTask(createdTask.id) await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] }) - navigate(`/entities/${entityId}`) - }, - }) - - const previewMutation = useMutation({ - mutationFn: generateTaskPreview, - onMutate: () => { - setPreview('') - setPreviewError('') - }, - onSuccess: (value) => setPreview(value), - onError: (error) => { - setPreviewError( - error instanceof Error - ? error.message - : 'Unable to generate a test message from the local model.' - ) + await queryClient.invalidateQueries({ queryKey: ['entity-task', createdTask.id] }) + navigate(`/entities/${entityId}/tasks/${createdTask.id}`) }, }) const canSubmit = useMemo(() => { const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0) - return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts) - }, [cronParts, taskForm.name, taskForm.prompt]) - - const currentTask = useMemo( - () => ({ - entityId, - name: taskForm.name, - prompt: taskForm.prompt, - scheduleCron: taskForm.scheduleCron, - emailLookback: taskForm.emailLookback, - }), - [entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron] - ) - - const finalPrompt = useMemo(() => { - if (!entity) { - return 'Entity details unavailable.' - } - - return buildTaskPreviewPrompt(entity, currentTask) - }, [currentTask, entity]) + return Boolean(taskForm.name.trim() && hasFilledCronParts) + }, [cronParts, taskForm.name]) const applyCronParts = (nextCronParts: CronParts) => { setCronParts(nextCronParts) @@ -186,7 +148,7 @@ export default function CreateTaskPage() { createTaskMutation.mutate({ entityId, name: taskForm.name, - prompt: taskForm.prompt, + prompt: '', scheduleCron: taskForm.scheduleCron, emailLookback: taskForm.emailLookback, }) @@ -205,21 +167,6 @@ export default function CreateTaskPage() { /> -
- -