taskSendMatch.test.ts5.6 KBView on GitHub
/**
 * The client's mirror of the server's two completion predicates.
 *
 * These are worth pinning precisely because the value of resolving a task optimistically is that
 * the server is about to agree. A rule that closes rows the server leaves `todo` buys a few
 * seconds of the right answer followed by the task popping back — worse than the lag it replaces.
 * So each case here is the client twin of a server case:
 *
 *   completeTaskByDraftId            — draftId, any output kind    (task-scheduling/execution.ts)
 *   markEmailTasksCompleteByThreadId — threadId, EMAIL kind only   (user-tasks/tasks.ts)
 *   outputKey()                      — task_output first, task_action_data as fallback
 */
import {
  isSatisfiedBySend,
  producedKey,
  tasksSatisfiedBySend,
  type SendMatchableTask,
} from '@/modules/userTasks/lib/task-send-match';

function task(overrides: Partial<SendMatchableTask> & { id: string }): SendMatchableTask {
  return { status: 'todo', conversationId: 'conv-1', ...overrides };
}

describe('producedKey — the COALESCE across both axes', () => {
  it('reads task_output first', () => {
    const t = task({
      id: 'a',
      taskOutput: { kind: 'email', draftId: 'new-axis' },
      taskActionData: { channel: 'email', draftId: 'legacy-axis' },
    });
    expect(producedKey(t, 'draftId')).toBe('new-axis');
  });

  it('falls back to the legacy task_action_data when the output axis lacks the key', () => {
    const t = task({
      id: 'a',
      taskOutput: { kind: 'email' },
      taskActionData: { channel: 'email', draftId: 'legacy-axis' },
    });
    expect(producedKey(t, 'draftId')).toBe('legacy-axis');
  });

  it('is null when neither axis carries the key', () => {
    expect(producedKey(task({ id: 'a', taskOutput: { kind: 'email' } }), 'draftId')).toBeNull();
    expect(producedKey(task({ id: 'a' }), 'threadId')).toBeNull();
  });
});

describe('isSatisfiedBySend', () => {
  it('closes a task whose draft the send consumed, whatever its output kind', () => {
    const slack = task({ id: 'a', taskOutput: { kind: 'slack', draftId: 'd1' } });
    expect(isSatisfiedBySend(slack, { draftId: 'd1' })).toBe(true);
  });

  it('closes an email task on the thread the send landed in', () => {
    const t = task({ id: 'a', taskOutput: { kind: 'email', threadId: 'thr-1' } });
    expect(isSatisfiedBySend(t, { threadId: 'thr-1' })).toBe(true);
  });

  it('matches Chris’s task shape — legacy payload only, no threadId on the output axis', () => {
    // The real row from mail.cedarcopilot.com/pipeline?threadOpen=1a0485f61e1e984f.
    const t = task({
      id: '979d93bb-2ec4-4549-9c21-35ef5e0c073b',
      taskOutput: { kind: 'email' },
      taskActionData: {
        channel: 'email',
        draftId: 'r6491224570646483348',
        threadId: '1a0485f61e1e984f',
      },
    });
    expect(isSatisfiedBySend(t, { threadId: '1a0485f61e1e984f' })).toBe(true);
    expect(isSatisfiedBySend(t, { draftId: 'r6491224570646483348' })).toBe(true);
  });

  it('leaves a NON-email task on the same thread alone', () => {
    // `isOutputKind('email')` is why: a calendar task riding the legacy email channel produces
    // no email, so an email going out on its thread is not evidence it was done.
    const calendar = task({
      id: 'a',
      taskOutput: { kind: 'calendar' },
      taskActionData: { channel: 'email', threadId: 'thr-1' },
    });
    expect(isSatisfiedBySend(calendar, { threadId: 'thr-1' })).toBe(false);
  });

  it('reads the output axis for the kind, not the stale declared channel', () => {
    // The 6 open rows whose channel says slack but whose payload holds an email draft. The
    // artifact is the truth, so these SHOULD close when their thread does.
    const t = task({
      id: 'a',
      taskOutput: { kind: 'email', threadId: 'thr-1' },
      taskActionData: { channel: 'slack', threadId: 'thr-1' },
    });
    expect(isSatisfiedBySend(t, { threadId: 'thr-1' })).toBe(true);
  });

  it('never closes a task that is not todo', () => {
    const done = task({ id: 'a', status: 'done', taskOutput: { kind: 'email', draftId: 'd1' } });
    expect(isSatisfiedBySend(done, { draftId: 'd1' })).toBe(false);
  });

  it('closes an id the caller named outright', () => {
    // `sendSlackDraftFromTask` calls completeTask itself, so no predicate has to find the row.
    expect(isSatisfiedBySend(task({ id: 'a' }), { taskIds: ['a'] })).toBe(true);
    expect(isSatisfiedBySend(task({ id: 'b' }), { taskIds: ['a'] })).toBe(false);
  });
});

describe('tasksSatisfiedBySend', () => {
  const tasks: SendMatchableTask[] = [
    task({ id: 'email-same-thread', taskOutput: { kind: 'email', threadId: 'thr-1' } }),
    task({ id: 'email-other-thread', taskOutput: { kind: 'email', threadId: 'thr-2' } }),
    task({ id: 'reminder-same-thread', taskOutput: { kind: 'none', threadId: 'thr-1' } }),
    task({ id: 'already-done', status: 'done', taskOutput: { kind: 'email', threadId: 'thr-1' } }),
  ];

  it('returns every open email task on the thread and nothing else', () => {
    expect(tasksSatisfiedBySend(tasks, { threadId: 'thr-1' })).toEqual(['email-same-thread']);
  });

  it('is empty when the send names no artifact — a free-text channel reply closes nothing', () => {
    expect(tasksSatisfiedBySend(tasks, {})).toEqual([]);
    expect(tasksSatisfiedBySend(tasks, { draftId: null, threadId: null })).toEqual([]);
  });

  it('de-duplicates a task matched by both draft and thread', () => {
    const both = [
      task({ id: 'a', taskOutput: { kind: 'email', threadId: 'thr-1', draftId: 'd1' } }),
    ];
    expect(tasksSatisfiedBySend(both, { threadId: 'thr-1', draftId: 'd1' })).toEqual(['a']);
  });
});