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