diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts index 7005815..ec682be 100644 --- a/frontend/src/__tests__/api/tasksApi.test.ts +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { activateTask, + buildTaskPreviewPrompt, createTask, deleteTask, + generateTaskPreview, getAllTasks, getTask, inactivateTask, @@ -33,6 +35,26 @@ const taskTwo: EntityTaskResponse = { createdAt: '2026-03-26T11:00:00Z', } +const entity = { + id: 'entity-1', + name: 'Milton Fiscal', + email: 'milton@condado.test', + jobTitle: 'Director of Ceremonial Logistics', + personality: 'Rigidly formal but secretly obsessed with office snacks.', + scheduleCron: '0 9 * * 1-5', + contextWindowDays: 7, + active: true, + createdAt: '2026-03-20T09:00:00Z', +} + +const previewTask = { + entityId: 'entity-1', + name: 'Snack Escalation Briefing', + prompt: 'Draft an absurdly official update about disappearing crackers.', + scheduleCron: '15 10 * * 2', + emailLookback: 'last_week' as const, +} + describe('tasksApi', () => { beforeEach(() => { localStorage.clear() @@ -54,14 +76,7 @@ describe('tasksApi', () => { it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => { localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) - await expect(getTasksByEntity('entity-1')).resolves.toEqual([ - taskOne, - { - ...taskOne, - id: 'task-3', - active: false, - }, - ]) + await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne]) }) it('should_hideInactiveTasks_when_getAllTasksCalled', async () => { @@ -70,7 +85,7 @@ describe('tasksApi', () => { await expect(getAllTasks()).resolves.toEqual([taskOne]) }) - it('should_hideInactiveTasksForEntity_when_getTasksByEntityCalled', async () => { + it('should_returnInactiveTasksForEntity_when_getTasksByEntityCalled', async () => { localStorage.setItem( 'condado:entity-tasks', JSON.stringify([ @@ -84,7 +99,14 @@ describe('tasksApi', () => { ]) ) - await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne]) + await expect(getTasksByEntity('entity-1')).resolves.toEqual([ + taskOne, + { + ...taskOne, + id: 'task-3', + active: false, + }, + ]) }) it('should_storeActiveTaskByDefault_when_createTaskCalled', async () => { @@ -185,4 +207,68 @@ describe('tasksApi', () => { expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([taskTwo]) }) + + it('should_buildDeterministicPrompt_when_buildTaskPreviewPromptCalled', () => { + expect(buildTaskPreviewPrompt(entity, previewTask)).toEqual(`You are preparing a test email message for a scheduled task in Condado Abaixo da Media SA. + +ENTITY DETAILS +- Entity ID: entity-1 +- Name: Milton Fiscal +- Email: milton@condado.test +- Job Title: Director of Ceremonial Logistics +- Personality: Rigidly formal but secretly obsessed with office snacks. +- Entity Schedule Cron: 0 9 * * 1-5 +- Context Window Days: 7 +- Active: true + +TASK DETAILS +- Task Name: Snack Escalation Briefing +- Task Prompt: Draft an absurdly official update about disappearing crackers. +- Task Schedule Cron: 15 10 * * 2 +- Email Lookback: Last week + +INSTRUCTIONS +- Write exactly one email message. +- Use an extremely formal corporate tone. +- Keep the content casual, mundane, and slightly nonsensical. +- Reflect the entity personality and task prompt faithfully. +- Output plain text only with no markdown fences.`) + }) + + it('should_callOllamaGenerateEndpoint_when_generateTaskPreviewCalled', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ response: 'SUBJECT: Memo\nBODY:\nPlease secure the crackers.' }), + }) + vi.stubGlobal('fetch', fetchSpy) + + await expect(generateTaskPreview({ entity, task: previewTask })).resolves.toBe( + 'SUBJECT: Memo\nBODY:\nPlease secure the crackers.' + ) + + expect(fetchSpy).toHaveBeenCalledWith('http://localhost:11434/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gemma3:4b', + prompt: buildTaskPreviewPrompt(entity, previewTask), + stream: false, + }), + }) + }) + + it('should_throwReadableError_when_ollamaRequestFails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ error: 'model temporarily unavailable' }), + }) + ) + + await expect(generateTaskPreview({ entity, task: previewTask })).rejects.toThrow( + 'Unable to generate a test message from the local model. model temporarily unavailable' + ) + }) }) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx index 651fc37..5c94f72 100644 --- a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx @@ -40,6 +40,10 @@ 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() }) @@ -98,11 +102,17 @@ describe('CreateTaskPage', () => { 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.generateTaskPreview).mock.calls[0][0]).toEqual( + expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ entityId: 'entity-1', name: 'Morning Blast', @@ -111,6 +121,18 @@ describe('CreateTaskPage', () => { 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() }) @@ -131,6 +153,26 @@ describe('CreateTaskPage', () => { }) }) + 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() + }) + }) + it('should_applyDailySuggestion_when_suggestionClicked', async () => { render(, { wrapper }) await screen.findByRole('link', { name: /back to entity a/i }) diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx index 2929334..5462e79 100644 --- a/frontend/src/__tests__/pages/EditTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -66,6 +66,10 @@ describe('EditTaskPage', () => { vi.clearAllMocks() vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask) + vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation( + (entity, task) => + `PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback}` + ) vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true }) vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false }) vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined) @@ -112,11 +116,19 @@ describe('EditTaskPage', () => { 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: Daily Check-in | Ask about ceremonial coffee | 0 8 * * 1-5 | last_day' + ) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) await waitFor(() => { expect(tasksApi.generateTaskPreview).toHaveBeenCalled() - expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual( + expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ entityId: 'entity-1', name: 'Daily Check-in', @@ -125,6 +137,18 @@ describe('EditTaskPage', () => { emailLookback: 'last_day', }) ) + expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual( + expect.objectContaining({ + entity: mockEntity, + task: expect.objectContaining({ + entityId: 'entity-1', + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }), + }) + ) expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument() }) @@ -145,6 +169,23 @@ describe('EditTaskPage', () => { }) }) + 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') + ) + + renderPage() + await screen.findByRole('link', { name: /back to entity a/i }) + + 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() + }) + }) + it('should_renderActivateButton_when_taskIsInactive', async () => { renderPage({ task: { ...mockTask, active: false } }) diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index fee8329..1666bc2 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -1,4 +1,8 @@ +import type { VirtualEntityResponse } from './entitiesApi' + const STORAGE_KEY = 'condado:entity-tasks' +const OLLAMA_GENERATE_URL = 'http://localhost:11434/api/generate' +const OLLAMA_MODEL = 'gemma3:4b' export type EmailLookback = 'last_day' | 'last_week' | 'last_month' @@ -23,6 +27,11 @@ export interface EntityTaskCreateDto { export type EntityTaskUpdateDto = EntityTaskCreateDto +export interface TaskPreviewRequest { + entity: VirtualEntityResponse + task: EntityTaskCreateDto +} + function readTasks(): EntityTaskResponse[] { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return [] @@ -49,22 +58,102 @@ export function getEmailLookbackLabel(value: EmailLookback): string { return 'Last week' } -/** Simulates a task preview generated from the configured prompt. */ -export async function generateTaskPreview(data: EntityTaskCreateDto): Promise { +function getEntityValue(value: string | null | undefined): string { + return value && value.trim().length > 0 ? value : 'Not provided' +} + +async function readOllamaError(response: Response): Promise { + try { + const data = (await response.json()) as { error?: string } + if (data.error?.trim()) { + return data.error + } + } catch { + return `Request failed with status ${response.status}` + } + + return `Request failed with status ${response.status}` +} + +function getReadablePreviewError(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message + } + + return 'Unknown error' +} + +export function buildTaskPreviewPrompt( + entity: VirtualEntityResponse, + task: EntityTaskCreateDto +): string { return [ - `SUBJECT: Internal Alignment Update - ${data.name}`, - 'BODY:', - `Dear Team,`, + 'You are preparing a test email message for a scheduled task in Condado Abaixo da Media SA.', '', - `In strict accordance with our communication standards, this message was generated from the prompt: "${data.prompt}".`, - `Context window considered: ${getEmailLookbackLabel(data.emailLookback)}.`, - 'Operational interpretation: please proceed casually, but with ceremonial seriousness.', + 'ENTITY DETAILS', + `- Entity ID: ${entity.id}`, + `- Name: ${entity.name}`, + `- Email: ${entity.email}`, + `- Job Title: ${entity.jobTitle}`, + `- Personality: ${getEntityValue(entity.personality)}`, + `- Entity Schedule Cron: ${getEntityValue(entity.scheduleCron)}`, + `- Context Window Days: ${entity.contextWindowDays}`, + `- Active: ${String(entity.active)}`, '', - 'Regards,', - 'Automated Task Preview', + 'TASK DETAILS', + `- Task Name: ${task.name}`, + `- Task Prompt: ${task.prompt}`, + `- Task Schedule Cron: ${task.scheduleCron}`, + `- Email Lookback: ${getEmailLookbackLabel(task.emailLookback)}`, + '', + 'INSTRUCTIONS', + '- Write exactly one email message.', + '- Use an extremely formal corporate tone.', + '- Keep the content casual, mundane, and slightly nonsensical.', + '- Reflect the entity personality and task prompt faithfully.', + '- Output plain text only with no markdown fences.', ].join('\n') } +/** Generates a task preview via the local Ollama model. */ +export async function generateTaskPreview({ entity, task }: TaskPreviewRequest): Promise { + const prompt = buildTaskPreviewPrompt(entity, task) + + try { + const response = await fetch(OLLAMA_GENERATE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + prompt, + stream: false, + }), + }) + + if (!response.ok) { + const message = await readOllamaError(response) + throw new Error(`Unable to generate a test message from the local model. ${message}`) + } + + const data = (await response.json()) as { response?: string } + const generatedMessage = data.response?.trim() + + if (!generatedMessage) { + throw new Error('Unable to generate a test message from the local model. The model returned an empty response.') + } + + return generatedMessage + } catch (error) { + const message = getReadablePreviewError(error) + + if (message.startsWith('Unable to generate a test message from the local model.')) { + throw error + } + + throw new Error(`Unable to generate a test message from the local model. ${message}`) + } +} + /** Returns all scheduled tasks currently configured in local storage. */ export async function getAllTasks(): Promise { return readTasks().filter((task) => task.active) diff --git a/frontend/src/pages/CreateTaskPage.tsx b/frontend/src/pages/CreateTaskPage.tsx index dc91199..e00341a 100644 --- a/frontend/src/pages/CreateTaskPage.tsx +++ b/frontend/src/pages/CreateTaskPage.tsx @@ -3,6 +3,7 @@ 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, type EmailLookback, @@ -83,6 +84,7 @@ export default function CreateTaskPage() { 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], @@ -100,7 +102,18 @@ export default function CreateTaskPage() { 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.' + ) + }, }) const canSubmit = useMemo(() => { @@ -108,6 +121,25 @@ export default function CreateTaskPage() { 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]) + const applyCronParts = (nextCronParts: CronParts) => { setCronParts(nextCronParts) setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) })) @@ -313,13 +345,8 @@ export default function CreateTaskPage() { type="button" onClick={() => { if (!canSubmit) return - previewMutation.mutate({ - entityId, - name: taskForm.name, - prompt: taskForm.prompt, - scheduleCron: taskForm.scheduleCron, - emailLookback: taskForm.emailLookback, - }) + if (!entity) return + previewMutation.mutate({ entity, task: currentTask }) }} disabled={!canSubmit || previewMutation.isPending} className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50" @@ -327,10 +354,30 @@ export default function CreateTaskPage() { {previewMutation.isPending ? 'Generating…' : 'Generate Test Message'} - {preview && ( -
-                {preview}
+            
+

+ Final Prompt +

+
+                {finalPrompt}
               
+
+ + {previewError && ( +

+ {previewError} +

+ )} + + {preview && ( +
+

+ Generated Message +

+
+                  {preview}
+                
+
)} diff --git a/frontend/src/pages/EditTaskPage.tsx b/frontend/src/pages/EditTaskPage.tsx index d3f8a24..cd251e7 100644 --- a/frontend/src/pages/EditTaskPage.tsx +++ b/frontend/src/pages/EditTaskPage.tsx @@ -4,6 +4,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom' import { getEntity } from '../api/entitiesApi' import { activateTask, + buildTaskPreviewPrompt, deleteTask, generateTaskPreview, getTask, @@ -115,6 +116,7 @@ export default function EditTaskPage() { 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: isLoadingEntity } = useQuery({ queryKey: ['entity', entityId], @@ -184,7 +186,18 @@ export default function EditTaskPage() { 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.' + ) + }, }) const canSubmit = useMemo(() => { @@ -192,6 +205,25 @@ export default function EditTaskPage() { 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]) + const applyCronParts = (nextCronParts: CronParts) => { setCronParts(nextCronParts) setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) })) @@ -402,13 +434,8 @@ export default function EditTaskPage() { type="button" onClick={() => { if (!canSubmit) return - previewMutation.mutate({ - entityId, - name: taskForm.name, - prompt: taskForm.prompt, - scheduleCron: taskForm.scheduleCron, - emailLookback: taskForm.emailLookback, - }) + if (!entity) return + previewMutation.mutate({ entity, task: currentTask }) }} disabled={!canSubmit || previewMutation.isPending} className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50" @@ -416,10 +443,30 @@ export default function EditTaskPage() { {previewMutation.isPending ? 'Generating…' : 'Generate Test Message'} - {preview && ( -
-                {preview}
+            
+

+ Final Prompt +

+
+                {finalPrompt}
               
+
+ + {previewError && ( +

+ {previewError} +

+ )} + + {preview && ( +
+

+ Generated Message +

+
+                  {preview}
+                
+
)}