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 });
  });
});