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