invoke-task-context-gating.test.tsx6.1 KBView on GitHub
/**
 * `useInvokeTaskInChat` — what an invocation puts into the chat's context.
 *
 * A task exists *about* a deal, so the thread that runs it is scoped to that deal: both the task
 * and its conversation are committed as context, from whatever surface Execute was pressed on.
 *
 * The gate these tests used to pin — conversation promoted only on the agent home, only into an
 * empty thread — conflated CONTEXT with DISPLAY. It was there to stop an Execute from a list
 * yanking the centre column onto a deal the user never opened, but that is held separately (the
 * silent-entry guard in EmbeddedCedarChat's landing effect), and holding it twice left the chat
 * unable to say which deal it was on.
 */

import { renderHook, act } from '@testing-library/react';

// ── Mocks ─────────────────────────────────────────────────────────────────────

const mockInvokeMutate = jest.fn();
jest.mock('@tanstack/react-query', () => ({
  useMutation: () => ({ mutateAsync: mockInvokeMutate }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    agentExecutions: { invokeTaskInChat: { mutationOptions: () => ({}) } },
  }),
}));

jest.mock('sonner', () => ({ toast: { error: jest.fn() } }));
jest.mock('@/modules/ux/layout/enterChatThread', () => ({
  markChatEntered: jest.fn(),
}));

type ChatContext = {
  primaryConversation?: { id: string } | null;
  items?: Array<{ kind: string; id: string }>;
};

const mockCalls = {
  addContextItem: [] as Array<{ threadId: string; item: Record<string, unknown> }>,
  attachPrimaryConversation: [] as Array<{ threadId: string; primary: Record<string, unknown> }>,
};

const mockStoreApi = {
  tasks: {} as Record<string, unknown>,
  threadMap: {} as Record<string, { chatContext?: ChatContext }>,
  chatInputContent: '',
  setTasks: jest.fn(),
  createThread: jest.fn(),
  switchThread: jest.fn(),
  pinThread: jest.fn(),
  loadThreadMessages: jest.fn().mockResolvedValue(undefined),
  setShowChat: jest.fn(),
  setChatInputContent: jest.fn(),
  sendMessage: jest.fn(),
  addContextItem: (threadId: string, item: Record<string, unknown>) => {
    mockCalls.addContextItem.push({ threadId, item });
    return Promise.resolve();
  },
  attachPrimaryConversation: (threadId: string, primary: Record<string, unknown>) => {
    mockCalls.attachPrimaryConversation.push({ threadId, primary });
    return Promise.resolve();
  },
};

jest.mock('@/modules/store', () => ({
  useCedarStore: Object.assign(jest.fn(), { getState: () => mockStoreApi }),
}));

import { useInvokeTaskInChat } from '@/modules/userTasks/hooks/use-invoke-task-in-chat';

// ── Helpers ───────────────────────────────────────────────────────────────────

const THREAD = 'th-1';
const CONVERSATION = 'conv-1';

function goTo(path: string) {
  window.history.pushState({}, '', path);
}

async function invokeFrom(
  path: string,
  threadContext?: ChatContext,
  conversationId: string | null = CONVERSATION,
) {
  goTo(path);
  mockStoreApi.threadMap = threadContext ? { [THREAD]: { chatContext: threadContext } } : {};

  const { result } = renderHook(() => useInvokeTaskInChat());
  await act(async () => {
    await result.current({
      taskId: 'task-1',
      conversationId,
      description: 'Send the recap',
      autonomous: true,
    });
  });
}

const ATTACHED = [{ threadId: THREAD, primary: { id: CONVERSATION } }];

beforeEach(() => {
  jest.clearAllMocks();
  mockCalls.addContextItem = [];
  mockCalls.attachPrimaryConversation = [];
  mockStoreApi.threadMap = {};
  mockInvokeMutate.mockResolvedValue({ chatThreadId: THREAD, seedPrompt: 'SEED' });
});

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('useInvokeTaskInChat — the task and its conversation are both committed', () => {
  it('attaches the TASK itself — it is the subject of the run', async () => {
    await invokeFrom('/tasks');
    expect(mockCalls.addContextItem).toHaveLength(1);
    expect(mockCalls.addContextItem[0].item).toMatchObject({ kind: 'task', id: 'task-1' });
  });

  it('promotes the conversation to primary when executing from the task board', async () => {
    await invokeFrom('/tasks');
    expect(mockCalls.attachPrimaryConversation).toEqual(ATTACHED);
  });

  it('promotes it from a conversation route too', async () => {
    await invokeFrom('/conversations');
    expect(mockCalls.attachPrimaryConversation).toEqual(ATTACHED);
  });

  it('promotes it on the agent home', async () => {
    await invokeFrom('/home');
    expect(mockCalls.attachPrimaryConversation).toEqual(ATTACHED);
  });

  it('promotes it on the /agent alias too', async () => {
    await invokeFrom('/agent');
    expect(mockCalls.attachPrimaryConversation).toEqual(ATTACHED);
  });
});

describe('useInvokeTaskInChat — a task thread is scoped to ITS deal', () => {
  // A task keeps one thread, so whatever primary the thread carries belongs to this task. Re-running
  // re-affirms it rather than deferring to a stale value; the store call is a no-op when it matches.
  it('re-affirms the primary even when the thread already carries one', async () => {
    await invokeFrom('/tasks', { primaryConversation: { id: 'a-previous-deal' } });
    expect(mockCalls.attachPrimaryConversation).toEqual(ATTACHED);
  });

  it('promotes it into a thread that already has other attached items', async () => {
    await invokeFrom('/tasks', { items: [{ kind: 'document', id: 'doc-1' }] });
    expect(mockCalls.attachPrimaryConversation).toEqual(ATTACHED);
  });

  it('leaves the primary slot empty for an orphan task with no conversation', async () => {
    await invokeFrom('/tasks', undefined, null);
    expect(mockCalls.attachPrimaryConversation).toHaveLength(0);
    // The task is still the subject of the run.
    expect(mockCalls.addContextItem).toHaveLength(1);
  });
});