taskUpdatedProcessor.test.ts7.2 KBView on GitHub /**
* `taskUpdated` is how a running agent tells the client it changed a task — most importantly, that
* save-draft just attached a draft to the task it was invoked for, which is what flips the /tasks
* row to "Open draft".
*
* The event carries the `user_tasks` ROW. The list surfaces render richer objects: the same row
* plus the `conversation` join that draws the company column. Applying the event by replacing the
* stored task therefore threw away everything the row alone doesn't carry — so at the exact moment
* a task produced a draft it also lost its company name, and (before the server serialized the
* group) jumped out of its lane into Misc. These tests pin the patch semantics.
*/
import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/store';
import { taskUpdatedProcessor } from '@/modules/cedar-os/src/store/agentConnection/responseProcessors/clientExecutionResponseProcessors';
import type { HydratedUserTask } from '@/modules/userTasks/slice/userTasksSlice';
const TASK_ID = '11111111-1111-4111-8111-111111111111';
const CONV_ID = '22222222-2222-4222-8222-222222222222';
/** A task as the /tasks list holds it: the row PLUS the conversation join the list rendered it with. */
function storedTask(): HydratedUserTask & { conversation?: { companyName: string } } {
return {
id: TASK_ID,
userId: 'u1',
conversationId: CONV_ID,
taskGroupId: 'group-responses',
taskChannel: 'email',
taskType: 'response',
taskCreatedBy: 'agent',
taskActionData: null,
agentExecutionEnabled: false,
executionRunId: null,
creationRunId: null,
notes: null,
chatThreadId: 'thread-1',
description: 'Send pricing follow-up',
status: 'todo',
isRead: false,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
completedAt: null,
dueDate: new Date('2026-01-02'),
taskOutput: null,
sourceThreadId: null,
sourceDocumentId: null,
sourceMarkerId: null,
tags: [],
sortOrder: 0,
sortOrderPinned: false,
conversation: { companyName: 'Acme Corp' },
};
}
/** The server's `serializeTaskForStream` payload for that row, now carrying a saved draft. */
function draftedEvent(overrides: Record<string, unknown> = {}) {
return {
type: 'taskUpdated' as const,
conversationId: CONV_ID,
task: {
id: TASK_ID,
userId: 'u1',
conversationId: CONV_ID,
description: 'Send pricing follow-up',
notes: null,
dueDate: '2026-01-02T00:00:00.000Z',
status: 'todo',
isRead: false,
taskChannel: 'email',
taskType: 'response',
taskCreatedBy: 'agent',
taskActionData: { channel: 'email', threadId: 'gmail-thread-9', draftId: 'draft-9' },
agentExecutionEnabled: false,
executionRunId: 'run-1',
creationRunId: null,
chatThreadId: 'thread-1',
taskGroupId: 'group-responses',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-03T00:00:00.000Z',
completedAt: null,
...overrides,
},
};
}
const seed = () =>
act(() => {
useCedarStore.setState((s) => ({ ...s, tasks: { [TASK_ID]: storedTask() } }));
});
const apply = async (event: ReturnType<typeof draftedEvent>) => {
const store = useCedarStore.getState();
await act(async () => {
await taskUpdatedProcessor.execute(event as never, store as never);
});
return useCedarStore.getState().tasks[TASK_ID]!;
};
describe('taskUpdatedProcessor — a drafted task keeps everything else about itself', () => {
beforeEach(seed);
it('attaches the draft', async () => {
const task = await apply(draftedEvent());
expect(task.taskActionData).toEqual({
channel: 'email',
threadId: 'gmail-thread-9',
draftId: 'draft-9',
});
});
it('keeps the conversation join the list renders the company column from', async () => {
const task = (await apply(draftedEvent())) as HydratedUserTask & {
conversation?: { companyName: string };
};
expect(task.conversation).toEqual({ companyName: 'Acme Corp' });
});
it('keeps the task in its own lane', async () => {
const task = await apply(draftedEvent());
expect(task.taskGroupId).toBe('group-responses');
});
it('honours a group MOVE the event actually reports', async () => {
const task = await apply(draftedEvent({ taskGroupId: 'group-misc-2' }));
expect(task.taskGroupId).toBe('group-misc-2');
});
it('treats a missing group as unknown, not as ungrouped (older server)', async () => {
const event = draftedEvent();
delete (event.task as Record<string, unknown>).taskGroupId;
const task = await apply(event);
expect(task.taskGroupId).toBe('group-responses');
});
it('still applies to a task the client has never seen', async () => {
act(() => {
useCedarStore.setState((s) => ({ ...s, tasks: {} }));
});
const task = await apply(draftedEvent());
expect(task.id).toBe(TASK_ID);
expect(task.taskActionData).toMatchObject({ draftId: 'draft-9' });
expect(task.taskGroupId).toBe('group-responses');
});
});
/**
* The event carries no board placement at all, but the branch below builds a WHOLE
* `ConversationUserTask` rather than a patch — there is no stored row to spread under it. Before
* `sortOrder`/`sortOrderPinned` were derived here, a task mirrored into a loaded conversation
* arrived with both undefined, and `orderOf` (utils/task-order.ts) floors undefined to 0, so the
* card silently sorted as if it were pinned to the top of its column.
*/
describe('taskUpdatedProcessor — mirroring into a loaded conversation carries placement', () => {
const seedConversation = (userTasks: unknown[]) =>
act(() => {
useCedarStore.setState((s) => ({
...s,
conversations: {
[CONV_ID]: {
// `setConversations` walks `conversation.events` and `userTasks` to build its
// thread→conversation index, so both must be present even when empty.
data: { id: CONV_ID, conversation: { id: CONV_ID, events: [] }, userTasks },
},
},
}));
});
const mirrored = () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((useCedarStore.getState().conversations[CONV_ID] as any).data.userTasks as Array<
Record<string, unknown>
>).find((t) => t.id === TASK_ID)!;
it('gives an inserted task a real placement instead of undefined', async () => {
seed();
seedConversation([]);
await apply(draftedEvent());
expect(mirrored()).toMatchObject({ sortOrder: 0, sortOrderPinned: false });
});
it('carries the placement the client already knew for that task', async () => {
act(() => {
useCedarStore.setState((s) => ({
...s,
tasks: { [TASK_ID]: { ...storedTask(), sortOrder: 42, sortOrderPinned: true } },
}));
});
seedConversation([]);
await apply(draftedEvent());
expect(mirrored()).toMatchObject({ sortOrder: 42, sortOrderPinned: true });
});
it('does not disturb the placement of a task already mirrored there', async () => {
seed();
seedConversation([{ id: TASK_ID, status: 'todo', sortOrder: 7, sortOrderPinned: true }]);
await apply(draftedEvent());
expect(mirrored()).toMatchObject({ sortOrder: 7, sortOrderPinned: true });
});
});