feat(frontend): generate task previews with local ollama
Replace the local preview stub with a real Ollama-backed test message flow using the configured local model. Show the exact final prompt live on create and edit task pages, render generated output below it, and cover the integration with frontend tests.
This commit is contained in:
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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(<CreateTaskPage />, { 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(<CreateTaskPage />, { wrapper })
|
||||
await screen.findByRole('link', { name: /back to entity a/i })
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user