diff --git a/apps/next/src/app/api/threads/[threadId]/message/route.ts b/apps/next/src/app/api/threads/[threadId]/message/route.ts index 2b0626d..d591458 100644 --- a/apps/next/src/app/api/threads/[threadId]/message/route.ts +++ b/apps/next/src/app/api/threads/[threadId]/message/route.ts @@ -1,3 +1,4 @@ +import type { FunctionReturnType } from 'convex/server'; import { NextResponse } from 'next/server'; import { proxyWorker } from '@/lib/agent-worker-proxy'; import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server'; @@ -35,7 +36,14 @@ export const POST = async ( { status: 400 }, ); } - const details = await fetchQuery(api.threads.get, { threadId }, { token }); + const details = (await fetchQuery( + api.threads.get, + { threadId }, + { token }, + )) as FunctionReturnType | null; + if (details === null) { + return NextResponse.json({ error: 'Thread not found.' }, { status: 404 }); + } const latestJob = details.latestJob; const canSendToWorker = latestJob && diff --git a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx index 923d300..19244f8 100644 --- a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx +++ b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { FunctionReturnType } from 'convex/server'; import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { useMutation, useQuery } from 'convex/react'; @@ -59,7 +60,10 @@ type PendingOverwrite = { }; export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { - const job = useQuery(api.agentJobs.get, { jobId }); + const job = useQuery(api.agentJobs.get, { jobId }) as + | FunctionReturnType + | null + | undefined; const messages = useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? []; const events = @@ -323,6 +327,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { ); } + if (job === null) { + return ( +
+ This workspace was not found. +
+ ); + } + const activeFile = activeFilePath ? files[activeFilePath] : undefined; const recoverWorkspace = async () => { if (!job.threadId) return; diff --git a/apps/next/tests/component/agent-workspace-shell.test.tsx b/apps/next/tests/component/agent-workspace-shell.test.tsx new file mode 100644 index 0000000..3e61bd9 --- /dev/null +++ b/apps/next/tests/component/agent-workspace-shell.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { AgentWorkspaceShell } from '../../src/components/agent-workspace/agent-workspace-shell'; + +const { mockUseMutation, mockUseQuery } = vi.hoisted(() => ({ + mockUseMutation: vi.fn(() => vi.fn()), + mockUseQuery: vi.fn(() => null), +})); + +vi.mock('convex/react', () => ({ + useMutation: mockUseMutation, + useQuery: mockUseQuery, +})); + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +describe('AgentWorkspaceShell', () => { + it('renders a missing state when the job query returns null', () => { + render(); + + expect( + screen.getByText('This workspace was not found.'), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/next/tests/unit/thread-message-route.test.ts b/apps/next/tests/unit/thread-message-route.test.ts new file mode 100644 index 0000000..759a27a --- /dev/null +++ b/apps/next/tests/unit/thread-message-route.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { POST } from '../../src/app/api/threads/[threadId]/message/route'; + +const { mockAuthToken, mockFetchMutation, mockFetchQuery, mockProxyWorker } = + vi.hoisted(() => ({ + mockAuthToken: vi.fn(), + mockFetchMutation: vi.fn(), + mockFetchQuery: vi.fn(), + mockProxyWorker: vi.fn(), + })); + +vi.mock('@convex-dev/auth/nextjs/server', () => ({ + convexAuthNextjsToken: mockAuthToken, +})); + +vi.mock('convex/nextjs', () => ({ + fetchMutation: mockFetchMutation, + fetchQuery: mockFetchQuery, +})); + +vi.mock('@/lib/agent-worker-proxy', () => ({ + proxyWorker: mockProxyWorker, +})); + +describe('thread message route', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuthToken.mockResolvedValue('token'); + }); + + it('returns 404 when the thread detail query returns null', async () => { + mockFetchQuery.mockResolvedValue(null); + + const response = await POST( + new Request('http://localhost/api/threads/thread-1/message', { + method: 'POST', + body: JSON.stringify({ content: 'Hello' }), + }), + { params: Promise.resolve({ threadId: 'thread-1' }) }, + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: 'Thread not found.', + }); + expect(mockFetchMutation).not.toHaveBeenCalled(); + expect(mockProxyWorker).not.toHaveBeenCalled(); + }); +});