diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 0a42cc1..bb24ea4 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,6 +1,16 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { Preview } from '@storybook/react-vite' import '../src/styles.css' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, +}) + const preview: Preview = { parameters: { darkMode: { @@ -21,9 +31,11 @@ const preview: Preview = { }, decorators: [ (Story) => ( -
- -
+ +
+ +
+
), ], } diff --git a/package-lock.json b/package-lock.json index 3b58d96..cbc426c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,8 @@ "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.0", "vite-tsconfig-paths": "^5.1.4", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zustand": "^5.0.6" }, "devDependencies": { "@chromatic-com/storybook": "^4.0.1", @@ -18542,6 +18543,35 @@ "zod": "^3.24.1" } }, + "node_modules/zustand": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz", + "integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 38f9f7b..52fcc86 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.0", "vite-tsconfig-paths": "^5.1.4", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zustand": "^5.0.6" }, "devDependencies": { "@chromatic-com/storybook": "^4.0.1", diff --git a/src/components/BackgroundJobItem.stories.tsx b/src/components/BackgroundJobItem.stories.tsx new file mode 100644 index 0000000..855e762 --- /dev/null +++ b/src/components/BackgroundJobItem.stories.tsx @@ -0,0 +1,85 @@ +import { BackgroundJobItem } from './BackgroundJobItem' +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { BackgroundJob } from '@/lib/schemas' + +const meta: Meta = { + title: 'Components/BackgroundJobItem', + component: BackgroundJobItem, + parameters: { + layout: 'padded', + }, + argTypes: { + onLoadResponse: { action: 'loadResponse' }, + onCancelJob: { action: 'cancelJob' }, + updateJob: { action: 'updateJob' }, + removeJob: { action: 'removeJob' }, + }, +} + +export default meta +type Story = StoryObj + +const baseJob: BackgroundJob = { + id: 'job-1', + status: 'running', + createdAt: '2024-01-15T10:30:00Z', + title: 'Analyzing code repository structure', +} + +export const Running: Story = { + args: { + job: baseJob, + }, +} + +export const Completed: Story = { + args: { + job: { + ...baseJob, + status: 'completed', + completedAt: '2024-01-15T10:35:00Z', + response: + 'Analysis complete. Found 15 components, 8 hooks, and 12 utility functions.', + }, + }, +} + +export const Failed: Story = { + args: { + job: { + ...baseJob, + status: 'failed', + completedAt: '2024-01-15T10:32:00Z', + }, + }, +} + +export const FailedWithResponse: Story = { + args: { + job: { + ...baseJob, + status: 'failed', + completedAt: '2024-01-15T10:32:00Z', + response: 'Partial analysis completed before error occurred.', + error: 'Network timeout during file analysis.', + }, + }, +} + +export const LongTitle: Story = { + args: { + job: { + ...baseJob, + title: + 'Performing comprehensive security audit of the entire application including all dependencies and configuration files', + }, + }, +} + +export const NoCallbacks: Story = { + args: { + job: baseJob, + onLoadResponse: undefined, + onCancelJob: undefined, + }, +} diff --git a/src/components/BackgroundJobItem.tsx b/src/components/BackgroundJobItem.tsx new file mode 100644 index 0000000..7e354b1 --- /dev/null +++ b/src/components/BackgroundJobItem.tsx @@ -0,0 +1,182 @@ +import { Clock, Play, Trash2, X } from 'lucide-react' +import { useEffect } from 'react' +import { Button } from './ui/button' +import type { BackgroundJob } from '@/lib/schemas' +import { useBackgroundJobStatus } from '@/hooks/useBackgroundJobStatus' + +export function BackgroundJobItem({ + job, + onLoadResponse, + onCancelJob, + updateJob, + removeJob, +}: { + job: BackgroundJob + onLoadResponse?: (jobId: string, response: string) => void + onCancelJob?: (jobId: string) => void + updateJob: (jobId: string, updates: Partial) => void + removeJob: (jobId: string) => void +}) { + // Use polling hook to check job status - continue polling until definitely complete or failed + const shouldPoll = + job.status === 'running' || (job.status === 'failed' && !job.completedAt) + + const { data: statusData } = useBackgroundJobStatus( + shouldPoll ? job : undefined, + 2000, + ) + + // Update job when status changes + useEffect(() => { + if (statusData) { + const needsUpdate = + statusData.status !== job.status || + statusData.response !== job.response || + statusData.error !== job.error || + statusData.completedAt !== job.completedAt + + if (needsUpdate) { + updateJob(job.id, { + status: statusData.status, + response: statusData.response || job.response, + error: statusData.error, + completedAt: statusData.completedAt || job.completedAt, + }) + } + } + }, [statusData, job, updateJob]) + + const handleLoadResponse = () => { + if (onLoadResponse) { + // If no response yet, load a placeholder or empty content + const responseContent = + job.response || + '[Job is still running - partial response will appear here]' + onLoadResponse(job.id, responseContent) + } + } + + const handleCancelJob = () => { + if (onCancelJob) { + onCancelJob(job.id) + } + // Update job status to indicate cancellation attempt + updateJob(job.id, { status: 'failed', error: 'Cancelled by user' }) + } + + const handleDeleteJob = () => { + removeJob(job.id) + } + + const getStatusIcon = (status: BackgroundJob['status']) => { + switch (status) { + case 'running': + return + case 'completed': + return + case 'failed': + return + default: + return + } + } + + const getStatusText = (status: BackgroundJob['status']) => { + switch (status) { + case 'running': + return 'Running' + case 'completed': + return 'Completed' + case 'failed': + return 'Failed' + default: + return 'Unknown' + } + } + + const formatTimestamp = (timestamp: string) => { + try { + return new Date(timestamp).toLocaleString() + } catch { + return timestamp + } + } + + return ( +
+
+
+ {getStatusIcon(job.status)} + {getStatusText(job.status)} +
+ +
+ + + {job.title} + + + {job.error && ( +
+ {job.error} +
+ )} + + +
+ ) +} diff --git a/src/components/BackgroundJobsSidebar.tsx b/src/components/BackgroundJobsSidebar.tsx new file mode 100644 index 0000000..9a249be --- /dev/null +++ b/src/components/BackgroundJobsSidebar.tsx @@ -0,0 +1,65 @@ +import { Clock } from 'lucide-react' +import { Sidebar } from './ui/sidebar' +import { BackgroundJobItem } from './BackgroundJobItem' +import { useHasMounted } from '@/hooks/useHasMounted' +import { useBackgroundJobs } from '@/hooks/useBackgroundJobs' + +interface BackgroundJobsSidebarProps { + isOpen: boolean + onClose: () => void + onLoadResponse?: (jobId: string, response: string) => void + onCancelJob?: (jobId: string) => void +} + +export function BackgroundJobsSidebar({ + isOpen, + onClose, + onLoadResponse, + onCancelJob, +}: BackgroundJobsSidebarProps) { + const { jobs, updateJob, removeJob } = useBackgroundJobs() + const hasMounted = useHasMounted() + + if (!hasMounted) { + return null + } + + return ( + +
+ {jobs.length === 0 ? ( +
+
+ ) : ( +
    + {jobs.map((job) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/src/components/BackgroundToggle.stories.tsx b/src/components/BackgroundToggle.stories.tsx new file mode 100644 index 0000000..5c26ab0 --- /dev/null +++ b/src/components/BackgroundToggle.stories.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { BackgroundToggle } from './BackgroundToggle' +import type { Meta, StoryObj } from '@storybook/react-vite' + +const meta: Meta = { + title: 'UI/BackgroundToggle', + component: BackgroundToggle, + tags: ['autodocs'], + argTypes: { + selectedModel: { + control: 'select', + options: ['gpt-4o', 'gpt-4.1', 'gpt-3.5-turbo', 'claude-3-opus'], + defaultValue: 'gpt-4o', + }, + disabled: { + control: 'boolean', + defaultValue: false, + }, + maxJobsReached: { + control: 'boolean', + defaultValue: false, + }, + }, +} +export default meta + +type Story = StoryObj + +const Template = (args: any) => { + const [useBackground, setUseBackground] = useState( + args.useBackground ?? false, + ) + return ( + + ) +} + +export const Default: Story = { + render: Template, + args: { + useBackground: false, + selectedModel: 'gpt-4o', + disabled: false, + maxJobsReached: false, + }, +} + +export const Enabled: Story = { + render: Template, + args: { + useBackground: true, + selectedModel: 'gpt-4o', + disabled: false, + maxJobsReached: false, + }, +} + +export const Disabled: Story = { + render: Template, + args: { + useBackground: false, + selectedModel: 'gpt-4o', + disabled: true, + maxJobsReached: false, + }, +} + +export const UnsupportedModel: Story = { + render: Template, + args: { + useBackground: false, + selectedModel: 'gpt-3.5-turbo', + disabled: false, + maxJobsReached: false, + }, +} + +export const MaxJobsReached: Story = { + render: Template, + args: { + useBackground: false, + selectedModel: 'gpt-4o', + disabled: false, + maxJobsReached: true, + }, +} + +export const UnsupportedModelWithMaxJobs: Story = { + render: Template, + args: { + useBackground: false, + selectedModel: 'claude-3-opus', + disabled: false, + maxJobsReached: true, + }, +} diff --git a/src/components/BackgroundToggle.tsx b/src/components/BackgroundToggle.tsx new file mode 100644 index 0000000..5059620 --- /dev/null +++ b/src/components/BackgroundToggle.tsx @@ -0,0 +1,40 @@ +import { Clock } from 'lucide-react' +import { ToolToggle } from './ToolToggle' +import { isBackgroundSupported } from '@/lib/background-supported-models' + +interface BackgroundToggleProps { + useBackground: boolean + onToggle: (enabled: boolean) => void + selectedModel: string + disabled?: boolean + maxJobsReached?: boolean +} + +export function BackgroundToggle({ + useBackground, + onToggle, + selectedModel, + disabled = false, + maxJobsReached = false, +}: BackgroundToggleProps) { + const isSupported = isBackgroundSupported(selectedModel) + + let tooltip = `Run requests in the background to continue using the chat interface.${!isSupported ? ` Not supported by ${selectedModel}` : ''}` + + if (maxJobsReached) { + tooltip = + 'Maximum number of concurrent background jobs reached. Please wait for existing jobs to complete.' + } + + return ( + } + label="Run in background" + tooltip={tooltip} + disabled={disabled || !isSupported || maxJobsReached} + /> + ) +} diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index fb51816..0cfd723 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useChat } from 'ai/react' -import { MessageSquarePlus } from 'lucide-react' +import { Clock, MessageSquarePlus } from 'lucide-react' import { useModel } from '../contexts/ModelContext' import { useUser } from '../contexts/UserContext' import { generateMessageId } from '../mcp/client' @@ -14,6 +14,8 @@ import { BotMessage } from './BotMessage' import { UserMessage } from './UserMessage' import { CodeInterpreterToggle } from './CodeInterpreterToggle' import { WebSearchToggle } from './WebSearchToggle' +import { BackgroundToggle } from './BackgroundToggle' +import { BackgroundJobsSidebar } from './BackgroundJobsSidebar' import { ModelSelect } from './ModelSelect' import { BotThinking } from './BotThinking' import { BotError } from './BotError' @@ -26,6 +28,7 @@ import { useStreamingChat } from '@/hooks/useStreamingChat' import { getTimestamp } from '@/lib/utils/date' import { isCodeInterpreterSupported } from '@/lib/utils/prompting' import { useHasMounted } from '@/hooks/useHasMounted' +import { useBackgroundJobs } from '@/hooks/useBackgroundJobs' const getEventKey = (event: StreamEvent | Message, idx: number): string => { if ('type' in event) { @@ -61,14 +64,19 @@ const TIMEOUT_ERROR_MESSAGE = export function Chat() { const hasMounted = useHasMounted() const messagesEndRef = useRef(null) + const lastPromptRef = useRef('') const [hasStartedChat, setHasStartedChat] = useState(false) const [focusTimestamp, setFocusTimestamp] = useState(Date.now()) const [servers, setServers] = useState({}) const [selectedServers, setSelectedServers] = useState>([]) const [useCodeInterpreter, setUseCodeInterpreter] = useState(false) const [useWebSearch, setUseWebSearch] = useState(false) + const [useBackground, setUseBackground] = useState(false) + const [backgroundJobsSidebarOpen, setBackgroundJobsSidebarOpen] = + useState(false) const { selectedModel, setSelectedModel } = useModel() const { user } = useUser() + const { jobs: backgroundJobs } = useBackgroundJobs() const { streamBuffer, @@ -110,6 +118,7 @@ export function Chat() { userId: user?.id, codeInterpreter: useCodeInterpreter, webSearch: useWebSearch, + background: useBackground, }), [ selectedServers, @@ -118,13 +127,28 @@ export function Chat() { user?.id, useCodeInterpreter, useWebSearch, + useBackground, ], ) + const handleResponseWithBackground = useCallback( + (response: Response) => { + if (useBackground) { + // The background job title is the last user prompt + const title = lastPromptRef.current + + handleResponse(response, { background: true, title }) + } else { + handleResponse(response, { background: false }) + } + }, + [handleResponse, useBackground], + ) + const { messages, isLoading, setMessages, append, stop } = useChat({ body: chatBody, onError: handleError, - onResponse: handleResponse, + onResponse: handleResponseWithBackground, }) const renderEvents = useMemo>(() => { @@ -143,6 +167,7 @@ export function Chat() { if (!hasStartedChat) { setHasStartedChat(true) } + lastPromptRef.current = prompt // Store the prompt for background job title addUserMessage(prompt) append({ role: 'user', content: prompt }) }, @@ -167,8 +192,78 @@ export function Chat() { setFocusTimestamp(Date.now()) setUseCodeInterpreter(false) setUseWebSearch(false) + setUseBackground(false) }, [setMessages, stop, cancelStream, clearBuffer]) + // Check if max concurrent background jobs limit is reached + const maxJobsReached = useMemo(() => { + if (!hasMounted) return false + const runningJobs = backgroundJobs.filter((job) => job.status === 'running') + return runningJobs.length >= 5 // Default limit from PRD + }, [hasMounted, backgroundJobs]) + + // Update chat messages when background jobs update (for streaming loaded responses) + useEffect(() => { + setMessages((prevMessages) => + prevMessages.map((message) => { + const job = backgroundJobs.find((j) => j.id === message.id) + if (job && job.response && job.response !== message.content) { + // Update message content with latest job response + return { ...message, content: job.response } + } + + return message + }), + ) + }, [backgroundJobs]) + + const handleLoadJobResponse = useCallback( + async (jobId: string) => { + const job = backgroundJobs.find((j) => j.id === jobId) + + if (!job || job.status === 'failed') { + console.warn('Job failed, cannot load response', job) + return + } + + try { + // Cancel any active stream and clear existing messages + cancelStream() + stop() + clearBuffer() + setMessages([]) + setHasStartedChat(true) + + const url = new URL('/api/background-jobs', window.location.origin) + url.searchParams.set('id', job.id) + const streamResponse = await fetch(url.toString(), { + method: 'GET', + }) + + setBackgroundJobsSidebarOpen(false) + handleResponse(streamResponse, { background: false }) + return + } catch (error) { + console.error('Failed to stream background job response:', error) + handleError(new Error('Failed to load background job')) + } + }, + [ + backgroundJobs, + cancelStream, + stop, + clearBuffer, + setMessages, + handleResponse, + handleError, + ], + ) + + const handleCancelJob = useCallback((jobId: string) => { + // TODO: Implement actual job cancellation via OpenAI API + console.log('Cancelling job:', jobId) + }, []) + const handleScrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, []) @@ -200,19 +295,46 @@ export function Chat() { /> ), }, + { + key: 'background', + isActive: useBackground, + component: ( + + ), + }, ] return (
- +
+ + {hasMounted && ( + + )} +
@@ -313,6 +435,12 @@ export function Chat() { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if ('type' in event && event.type === 'web_search') { return + } else if ( + 'type' in event && + event.type === 'background_job_created' + ) { + // Background job handled by streaming hook - no UI rendering needed + return null } else { // Fallback for Message type (from useChat) const message = event @@ -439,6 +567,12 @@ export function Chat() { focusTimestamp={focusTimestamp} />
+ setBackgroundJobsSidebarOpen(false)} + onLoadResponse={handleLoadJobResponse} + onCancelJob={handleCancelJob} + />
) } diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..f56d463 --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' +import { X } from 'lucide-react' +import { Button } from './button' +import { cn } from '@/lib/utils' + +interface SidebarProps { + children: React.ReactNode + isOpen: boolean + onClose: () => void + title: string + className?: string +} + +export function Sidebar({ + children, + isOpen, + onClose, + title, + className, +}: SidebarProps) { + return ( + <> + {/* Overlay */} + {isOpen && ( +
+ )} + + {/* Sidebar */} +
+
+

{title}

+ +
+
+ {children} +
+
+ + ) +} diff --git a/src/hooks/useBackgroundJobStatus.test.tsx b/src/hooks/useBackgroundJobStatus.test.tsx new file mode 100644 index 0000000..a1ef52f --- /dev/null +++ b/src/hooks/useBackgroundJobStatus.test.tsx @@ -0,0 +1,264 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useBackgroundJobStatus } from './useBackgroundJobStatus' +import { getTimestamp } from '@/lib/utils/date' + +describe('useBackgroundJobStatus', () => { + let mockFetch: ReturnType + + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchInterval: false, // Disable polling for simpler tests + }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) + } + + beforeEach(() => { + mockFetch = vi.fn() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('when job is running', () => { + it('fetches job status and returns data', async () => { + const job = { + id: 'job-1', + status: 'running' as const, + createdAt: getTimestamp(), + } + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'completed', response: 'Success!' }), + }) + + const { result } = renderHook(() => useBackgroundJobStatus(job), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.data).toEqual({ + status: 'completed', + response: 'Success!', + }) + }) + + expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'job-1' }), + }) + }) + + it('handles different status responses', async () => { + const job = { + id: 'job-2', + status: 'running' as const, + createdAt: getTimestamp(), + } + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'failed', + error: 'Something went wrong', + }), + }) + + const { result } = renderHook(() => useBackgroundJobStatus(job), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.data?.status).toBe('failed') + expect(result.current.data?.error).toBe('Something went wrong') + }) + }) + }) + + describe('when job is completed', () => { + it('does not fetch job status', () => { + const job = { + id: 'job-3', + status: 'completed' as const, + createdAt: getTimestamp(), + } + + const { result } = renderHook(() => useBackgroundJobStatus(job), { + wrapper: createWrapper(), + }) + + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toBeUndefined() + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + + describe('when job is failed', () => { + it('does not fetch job status when completedAt is present', () => { + const job = { + id: 'job-4', + status: 'failed' as const, + createdAt: getTimestamp(), + completedAt: getTimestamp(), + } + + const { result } = renderHook(() => useBackgroundJobStatus(job), { + wrapper: createWrapper(), + }) + + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toBeUndefined() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('fetches job status when completedAt is missing', async () => { + const job = { + id: 'job-5', + status: 'failed' as const, + createdAt: getTimestamp(), + } + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'failed', + error: 'Job failed', + completedAt: getTimestamp(), + }), + }) + + const { result } = renderHook(() => useBackgroundJobStatus(job), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.data).toEqual({ + status: 'failed', + error: 'Job failed', + completedAt: expect.any(String), + }) + }) + + expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'job-5' }), + }) + }) + }) + + describe('when job is undefined', () => { + it('does not fetch and returns undefined', () => { + const { result } = renderHook(() => useBackgroundJobStatus(undefined), { + wrapper: createWrapper(), + }) + + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toBeUndefined() + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + const createErrorWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchInterval: false, + }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + } + + it('handles HTTP 404 errors without retrying', async () => { + const job = { + id: 'job-6', + status: 'running' as const, + createdAt: getTimestamp(), + } + + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + }) + + const { result } = renderHook(() => useBackgroundJobStatus(job), { + wrapper: createErrorWrapper(), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toContain('404') + // Should only be called once (no retries for 4xx errors) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('calls fetch with correct parameters for error scenarios', async () => { + const job = { + id: 'job-7', + status: 'running' as const, + createdAt: getTimestamp(), + } + + mockFetch.mockRejectedValue(new Error('Network error')) + + renderHook(() => useBackgroundJobStatus(job), { + wrapper: createErrorWrapper(), + }) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'job-7' }), + }) + }) + }) + + it('calls fetch for HTTP error responses', async () => { + const job = { + id: 'job-8', + status: 'running' as const, + createdAt: getTimestamp(), + } + + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + }) + + renderHook(() => useBackgroundJobStatus(job), { + wrapper: createErrorWrapper(), + }) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'job-8' }), + }) + }) + }) + }) +}) diff --git a/src/hooks/useBackgroundJobStatus.ts b/src/hooks/useBackgroundJobStatus.ts new file mode 100644 index 0000000..c15ce19 --- /dev/null +++ b/src/hooks/useBackgroundJobStatus.ts @@ -0,0 +1,72 @@ +import { useQuery } from '@tanstack/react-query' +import type { BackgroundJob } from '../lib/schemas' + +interface BackgroundJobStatusResponse { + status: 'running' | 'completed' | 'failed' + response?: string + error?: string + completedAt?: string +} + +class HttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message) + this.name = 'HttpError' + } +} + +const fetchJobStatus = async ( + id: string, +): Promise => { + const response = await fetch('/api/background-jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to fetch job status: ${response.status}`, + ) + } + + return response.json() +} + +export const useBackgroundJobStatus = ( + job: BackgroundJob | undefined, + refetchInterval: number = 2000, +) => { + return useQuery({ + queryKey: ['backgroundJobStatus', job?.id], + queryFn: () => { + if (!job?.id) { + throw new Error('No request ID available') + } + return fetchJobStatus(job.id) + }, + enabled: + !!job?.id && + (job.status === 'running' || + (job.status === 'failed' && !job.completedAt)), + refetchInterval, + refetchIntervalInBackground: true, + retry: (failureCount, error) => { + // Don't retry on 4xx errors (client errors) + if ( + error instanceof HttpError && + error.status >= 400 && + error.status < 500 + ) { + return false + } + // Retry network errors and 5xx errors up to 3 times + return failureCount < 3 + }, + retryDelay: 1000, + }) +} diff --git a/src/hooks/useBackgroundJobs.test.tsx b/src/hooks/useBackgroundJobs.test.tsx new file mode 100644 index 0000000..32d4e6b --- /dev/null +++ b/src/hooks/useBackgroundJobs.test.tsx @@ -0,0 +1,455 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useBackgroundJobs } from './useBackgroundJobs' +import { + BACKGROUND_JOBS_STORAGE_KEY, + useBackgroundJobsStore, +} from './useBackgroundJobsStore' +import type { BackgroundJob } from '../lib/schemas' + +describe('useBackgroundJobs', () => { + beforeEach(() => { + const { result } = renderHook(() => useBackgroundJobs()) + act(() => { + result.current.clearAllJobs() + }) + }) + + const createMockJob = ( + overrides: Partial = {}, + ): BackgroundJob => ({ + id: 'resp_1234567890abcdef1234567890abcdef12345678', + status: 'running', + createdAt: '2025-01-01T00:00:00Z', + title: 'Test Job', + ...overrides, + }) + + describe('initialization and persistence', () => { + it('should reset jobs using resetJobs()', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const job = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef12', + }) + act(() => { + result.current.addJob(job) + }) + expect(result.current.jobs).toHaveLength(1) + act(() => { + result.current.clearAllJobs() + }) + expect(result.current.jobs).toHaveLength(0) + expect(result.current.jobsMap).toEqual({}) + }) + it('should start with empty jobs when no data in localStorage', () => { + const { result } = renderHook(() => useBackgroundJobs()) + + expect(result.current.jobs).toEqual([]) + expect(result.current.jobsMap).toEqual({}) + }) + + it('should load existing jobs from localStorage', () => { + const job1 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef11', + }) + const job2 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef22', + status: 'completed', + }) + const storedData = { job1, job2 } + + // Initialize store with existing data using proper API + useBackgroundJobsStore.getState().initializeJobs(storedData) + + const { result } = renderHook(() => useBackgroundJobs()) + + expect(result.current.jobs).toHaveLength(2) + expect(result.current.jobs).toContainEqual(job1) + expect(result.current.jobs).toContainEqual(job2) + expect(result.current.jobsMap).toEqual(storedData) + }) + + it('should persist jobs across hook instances', () => { + const job = createMockJob() + + // First hook instance adds a job + const { result: firstResult, unmount } = renderHook(() => + useBackgroundJobs(), + ) + act(() => { + firstResult.current.addJob(job) + }) + unmount() + + // Second hook instance should load the persisted job + const { result: secondResult } = renderHook(() => useBackgroundJobs()) + expect(secondResult.current.jobs).toContainEqual(job) + }) + }) + + describe('job management', () => { + it('should add a job and make it available', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const newJob = createMockJob() + + act(() => { + result.current.addJob(newJob) + }) + + expect(result.current.jobs).toContainEqual(newJob) + expect(result.current.jobsMap[newJob.id]).toEqual(newJob) + expect(result.current.getJobById(newJob.id)).toEqual(newJob) + }) + + it('should add multiple jobs', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const job1 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef11', + title: 'Job 1', + }) + const job2 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef22', + title: 'Job 2', + }) + + expect(result.current.jobs).toHaveLength(0) + + act(() => { + result.current.addJob(job1) + }) + + expect(result.current.jobs).toHaveLength(1) + expect(result.current.jobs).toContainEqual(job1) + + act(() => { + result.current.addJob(job2) + }) + + expect(result.current.jobs).toHaveLength(2) + expect(result.current.jobs).toContainEqual(job1) + expect(result.current.jobs).toContainEqual(job2) + }) + + it('should overwrite job with same id', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const originalJob = createMockJob({ status: 'running' }) + const updatedJob = createMockJob({ status: 'completed' }) + + act(() => { + result.current.addJob(originalJob) + result.current.addJob(updatedJob) + }) + + expect(result.current.jobs).toHaveLength(1) + expect(result.current.getJobById(updatedJob.id)).toEqual(updatedJob) + expect(result.current.getJobById(updatedJob.id)?.status).toBe('completed') + }) + + it('should remove a job by id', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const job1 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef11', + }) + const job2 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef22', + }) + + act(() => { + result.current.addJob(job1) + result.current.addJob(job2) + }) + + act(() => { + result.current.removeJob( + 'resp_abcdef1234567890abcdef1234567890abcdef11', + ) + }) + + expect(result.current.jobs).toHaveLength(1) + expect(result.current.jobs).toContainEqual(job2) + expect( + result.current.getJobById( + 'resp_abcdef1234567890abcdef1234567890abcdef11', + ), + ).toBeUndefined() + expect( + result.current.getJobById( + 'resp_abcdef1234567890abcdef1234567890abcdef22', + ), + ).toEqual(job2) + }) + + it('should handle removing non-existent job gracefully', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const job = createMockJob() + + act(() => { + result.current.addJob(job) + }) + + act(() => { + result.current.removeJob('non-existent') + }) + + expect(result.current.jobs).toHaveLength(1) + expect(result.current.jobs).toContainEqual(job) + }) + + it('should update existing job with partial data', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const job = createMockJob({ status: 'running' }) + + act(() => { + result.current.addJob(job) + }) + + act(() => { + result.current.updateJob(job.id, { + status: 'completed', + completedAt: '2025-01-01T01:00:00Z', + }) + }) + + const updatedJob = result.current.getJobById(job.id) + expect(updatedJob?.status).toBe('completed') + expect(updatedJob?.completedAt).toBe('2025-01-01T01:00:00Z') + expect(updatedJob?.title).toBe(job.title) // Should preserve other fields + }) + + it('should handle updating non-existent job gracefully', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const existingJob = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef99', + }) + + act(() => { + result.current.addJob(existingJob) + }) + + act(() => { + result.current.updateJob('non-existent', { status: 'completed' }) + }) + + // Should not affect existing jobs and should not create undefined entries + expect(result.current.jobs).toHaveLength(1) + expect(result.current.jobs).toContainEqual(existingJob) + expect(result.current.getJobById('non-existent')).toBeUndefined() + }) + + it('should clear all jobs', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const job1 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef11', + }) + const job2 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef22', + }) + + act(() => { + result.current.addJob(job1) + result.current.addJob(job2) + }) + + act(() => { + result.current.clearAllJobs() + }) + + expect(result.current.jobs).toHaveLength(0) + expect(result.current.jobsMap).toEqual({}) + }) + }) + + describe('real-world scenarios', () => { + it('should handle complete job lifecycle with persistence', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const job = createMockJob() + + act(() => { + result.current.addJob(job) + }) + + act(() => { + result.current.updateJob(job.id, { + status: 'completed', + response: 'Job completed successfully', + completedAt: '2025-01-01T01:00:00Z', + }) + }) + + const finalJob = result.current.getJobById(job.id) + expect(finalJob?.status).toBe('completed') + expect(finalJob?.response).toBe('Job completed successfully') + expect(finalJob?.completedAt).toBe('2025-01-01T01:00:00Z') + + const storedData = JSON.parse( + localStorage.getItem(BACKGROUND_JOBS_STORAGE_KEY) || '{}', + ) + expect(storedData.state.jobs[job.id]).toEqual(finalJob) + }) + + it('should handle concurrent job operations', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const runningJob = createMockJob({ + id: 'resp_1111111111111111111111111111111111111111', + status: 'running', + title: 'Running Job', + }) + const completedJob = createMockJob({ + id: 'resp_2222222222222222222222222222222222222222', + status: 'completed', + title: 'Completed Job', + }) + const failedJob = createMockJob({ + id: 'resp_3333333333333333333333333333333333333333', + status: 'failed', + title: 'Failed Job', + }) + + expect(result.current.jobs).toHaveLength(0) + // Zustand persist middleware always creates localStorage entry, so check it has empty jobs + const storedData = JSON.parse( + localStorage.getItem(BACKGROUND_JOBS_STORAGE_KEY) || '{}', + ) + expect(storedData.state?.jobs).toEqual({}) + + act(() => { + result.current.addJob(runningJob) + }) + expect(result.current.jobs).toHaveLength(1) + + act(() => { + result.current.addJob(completedJob) + }) + expect(result.current.jobs).toHaveLength(2) + + act(() => { + result.current.addJob(failedJob) + }) + + expect(result.current.jobs).toHaveLength(3) + + act(() => { + result.current.removeJob( + 'resp_2222222222222222222222222222222222222222', + ) + }) + + act(() => { + result.current.updateJob( + 'resp_1111111111111111111111111111111111111111', + { + status: 'completed', + }, + ) + }) + + expect(result.current.jobs).toHaveLength(2) + expect( + result.current.getJobById( + 'resp_1111111111111111111111111111111111111111', + )?.status, + ).toBe('completed') + expect( + result.current.getJobById( + 'resp_2222222222222222222222222222222222222222', + ), + ).toBeUndefined() + expect( + result.current.getJobById( + 'resp_3333333333333333333333333333333333333333', + ), + ).toEqual(failedJob) + }) + + it('should maintain data integrity across browser sessions', () => { + const job1 = createMockJob({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + title: 'Session 1 Job', + }) + const job2 = createMockJob({ + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + title: 'Session 2 Job', + }) + + const firstSessionResult = renderHook(() => useBackgroundJobs()) + + expect(firstSessionResult.result.current.jobs).toHaveLength(0) + // Zustand persist middleware always creates localStorage entry, so check it has empty jobs + const initialStoredData = JSON.parse( + localStorage.getItem(BACKGROUND_JOBS_STORAGE_KEY) || '{}', + ) + expect(initialStoredData.state?.jobs).toEqual({}) + + act(() => { + firstSessionResult.result.current.addJob(job1) + }) + expect(firstSessionResult.result.current.jobs).toHaveLength(1) + + act(() => { + firstSessionResult.result.current.addJob(job2) + }) + + expect(firstSessionResult.result.current.jobs).toHaveLength(2) + firstSessionResult.unmount() + + const secondSessionResult = renderHook(() => useBackgroundJobs()) + expect(secondSessionResult.result.current.jobs).toHaveLength(2) + expect( + secondSessionResult.result.current.getJobById( + 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + ), + ).toEqual(job1) + expect( + secondSessionResult.result.current.getJobById( + 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + ), + ).toEqual(job2) + + act(() => { + secondSessionResult.result.current.updateJob( + 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + { + status: 'completed', + }, + ) + }) + + expect( + secondSessionResult.result.current.getJobById( + 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + )?.status, + ).toBe('completed') + + act(() => { + secondSessionResult.result.current.removeJob( + 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + ) + }) + + expect(secondSessionResult.result.current.jobs).toHaveLength(1) + expect( + secondSessionResult.result.current.getJobById( + 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + ), + ).toBeUndefined() + + secondSessionResult.unmount() + + const thirdSessionResult = renderHook(() => useBackgroundJobs()) + expect(thirdSessionResult.result.current.jobs).toHaveLength(1) + + const persistedJob = thirdSessionResult.result.current.getJobById( + 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + ) + expect(persistedJob).toBeDefined() + expect(persistedJob?.status).toBe('completed') + expect(persistedJob?.title).toBe('Session 1 Job') // Should preserve other fields + expect( + thirdSessionResult.result.current.getJobById( + 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + ), + ).toBeUndefined() + thirdSessionResult.unmount() + }) + }) +}) diff --git a/src/hooks/useBackgroundJobs.ts b/src/hooks/useBackgroundJobs.ts new file mode 100644 index 0000000..37f9bca --- /dev/null +++ b/src/hooks/useBackgroundJobs.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react' +import { useBackgroundJobsStore } from './useBackgroundJobsStore' +import type { BackgroundJob } from '../lib/schemas' + +interface UseBackgroundJobsReturn { + jobs: Array + jobsMap: Record + addJob: (job: BackgroundJob) => void + removeJob: (id: string) => void + updateJob: (id: string, updates: Partial) => void + getJobById: (id: string) => BackgroundJob | undefined + clearAllJobs: () => void +} + +export const useBackgroundJobs = (): UseBackgroundJobsReturn => { + const jobsMap = useBackgroundJobsStore((state) => state.jobs) + const addJob = useBackgroundJobsStore((state) => state.addJob) + const removeJob = useBackgroundJobsStore((state) => state.removeJob) + const updateJob = useBackgroundJobsStore((state) => state.updateJob) + const clearAllJobs = useBackgroundJobsStore((state) => state.clearAllJobs) + + const jobs = useMemo(() => Object.values(jobsMap).filter(Boolean), [jobsMap]) + + const getJobById = (id: string) => { + return jobsMap[id] + } + + return { + jobs, + jobsMap, + addJob, + removeJob, + updateJob, + getJobById, + clearAllJobs, + } +} diff --git a/src/hooks/useBackgroundJobsStore.test.ts b/src/hooks/useBackgroundJobsStore.test.ts new file mode 100644 index 0000000..b84e09f --- /dev/null +++ b/src/hooks/useBackgroundJobsStore.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useBackgroundJobsStore } from './useBackgroundJobsStore' +import type { BackgroundJob } from '@/lib/schemas' + +describe('useBackgroundJobsStore', () => { + beforeEach(() => { + // Reset store state using proper API + useBackgroundJobsStore.getState().clearAllJobs() + }) + + const mockJob: BackgroundJob = { + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8', + status: 'running', + createdAt: '2023-01-01T00:00:00.000Z', + title: 'Test Job', + } + + describe('addJob', () => { + it('should add a job to the store', () => { + const { addJob } = useBackgroundJobsStore.getState() + + addJob(mockJob) + + const updatedState = useBackgroundJobsStore.getState() + expect(updatedState.jobs).toEqual({ + resp_6880e725623081a1af3dc14ba0d562620d62da8: mockJob, + }) + }) + + it('should validate job data when adding', () => { + const { addJob } = useBackgroundJobsStore.getState() + const invalidJob = { + id: '', + status: 'invalid' as any, + createdAt: '2023-01-01T00:00:00.000Z', + } + + expect(() => addJob(invalidJob)).toThrow() + }) + + it('should add multiple jobs', () => { + const { addJob } = useBackgroundJobsStore.getState() + const job2: BackgroundJob = { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', + status: 'completed', + createdAt: '2023-01-01T01:00:00.000Z', + completedAt: '2023-01-01T01:05:00.000Z', + } + + addJob(mockJob) + addJob(job2) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual({ + resp_6880e725623081a1af3dc14ba0d562620d62da8: mockJob, + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7: job2, + }) + }) + + it('should overwrite existing job with same id', () => { + const { addJob } = useBackgroundJobsStore.getState() + const updatedJob: BackgroundJob = { + ...mockJob, + status: 'completed', + completedAt: '2023-01-01T00:05:00.000Z', + } + + addJob(mockJob) + addJob(updatedJob) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8']).toEqual( + updatedJob, + ) + expect(Object.keys(jobs)).toHaveLength(1) + }) + }) + + describe('removeJob', () => { + beforeEach(() => { + useBackgroundJobsStore.getState().addJob(mockJob) + }) + + it('should remove a job from the store', () => { + const { removeJob } = useBackgroundJobsStore.getState() + + removeJob('resp_6880e725623081a1af3dc14ba0d562620d62da8') + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual({}) + }) + + it('should not affect other jobs when removing one', () => { + const job2: BackgroundJob = { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', + status: 'running', + createdAt: '2023-01-01T01:00:00.000Z', + } + const { addJob, removeJob } = useBackgroundJobsStore.getState() + + addJob(job2) + removeJob('resp_6880e725623081a1af3dc14ba0d562620d62da8') + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual({ + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7: job2, + }) + }) + + it('should handle removing non-existent job gracefully', () => { + const { removeJob } = useBackgroundJobsStore.getState() + const initialJobs = useBackgroundJobsStore.getState().jobs + + removeJob('non-existent') + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual(initialJobs) + }) + + it('should throw error for empty job ID', () => { + const { removeJob } = useBackgroundJobsStore.getState() + + expect(() => removeJob('')).toThrow('Job ID is required') + expect(() => removeJob(' ')).toThrow('Job ID is required') + }) + }) + + describe('updateJob', () => { + beforeEach(() => { + useBackgroundJobsStore.getState().addJob(mockJob) + }) + + it('should update an existing job', () => { + const { updateJob } = useBackgroundJobsStore.getState() + const updates = { + status: 'completed' as const, + completedAt: '2023-01-01T00:05:00.000Z', + response: 'Job completed successfully', + } + + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da8', updates) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8']).toEqual({ + ...mockJob, + ...updates, + }) + }) + + it('should partially update job properties', () => { + const { updateJob } = useBackgroundJobsStore.getState() + + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da8', { + status: 'failed', + error: 'Something went wrong', + }) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8']).toEqual({ + ...mockJob, + status: 'failed', + error: 'Something went wrong', + }) + }) + + it('should not update non-existent job', () => { + const { updateJob } = useBackgroundJobsStore.getState() + const initialJobs = useBackgroundJobsStore.getState().jobs + + updateJob('non-existent', { status: 'completed' }) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual(initialJobs) + }) + + it('should not modify other jobs when updating one', () => { + const job2: BackgroundJob = { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', + status: 'running', + createdAt: '2023-01-01T01:00:00.000Z', + } + const { addJob, updateJob } = useBackgroundJobsStore.getState() + + addJob(job2) + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da8', { + status: 'completed', + }) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs['resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7']).toEqual(job2) + }) + + it('should throw error for empty job ID', () => { + const { updateJob } = useBackgroundJobsStore.getState() + + expect(() => updateJob('', { status: 'completed' })).toThrow( + 'Job ID is required', + ) + expect(() => updateJob(' ', { status: 'completed' })).toThrow( + 'Job ID is required', + ) + }) + + it('should validate updated job data', () => { + const { updateJob } = useBackgroundJobsStore.getState() + + // Should throw for invalid status + expect(() => + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da8', { + status: 'invalid' as any, + }), + ).toThrow() + }) + }) + + describe('clearAllJobs', () => { + beforeEach(() => { + const { addJob } = useBackgroundJobsStore.getState() + addJob(mockJob) + addJob({ + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', + status: 'completed', + createdAt: '2023-01-01T01:00:00.000Z', + }) + }) + + it('should clear all jobs from the store', () => { + const { clearAllJobs } = useBackgroundJobsStore.getState() + + clearAllJobs() + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual({}) + }) + }) + + describe('initializeJobs', () => { + it('should initialize store with provided jobs', () => { + const { initializeJobs } = useBackgroundJobsStore.getState() + const initialJobs = { + resp_6880e725623081a1af3dc14ba0d562620d62da8: mockJob, + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7: { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', + status: 'completed' as const, + createdAt: '2023-01-01T01:00:00.000Z', + }, + } + + initializeJobs(initialJobs) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual(initialJobs) + }) + + it('should replace existing jobs when initializing', () => { + const { addJob, initializeJobs } = useBackgroundJobsStore.getState() + + addJob(mockJob) + + // Initialize with different jobs + const newJobs = { + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7: { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', + status: 'completed' as const, + createdAt: '2023-01-01T01:00:00.000Z', + }, + } + + initializeJobs(newJobs) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual(newJobs) + expect( + jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8'], + ).toBeUndefined() + }) + + it('should validate all jobs when initializing', () => { + const { initializeJobs } = useBackgroundJobsStore.getState() + const invalidJobs = { + resp_6880e725623081a1af3dc14ba0d562620d62da8: { + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8', + status: 'invalid' as any, + createdAt: '2023-01-01T00:00:00.000Z', + }, + } + + expect(() => initializeJobs(invalidJobs)).toThrow() + }) + }) + + describe('persistence', () => { + it('should use correct localStorage key', () => { + // The persist middleware should use 'background-jobs' as the key + // This is tested implicitly through the middleware configuration + expect(true).toBe(true) // Placeholder - actual persistence testing requires more complex setup + }) + }) +}) diff --git a/src/hooks/useBackgroundJobsStore.ts b/src/hooks/useBackgroundJobsStore.ts new file mode 100644 index 0000000..cc53499 --- /dev/null +++ b/src/hooks/useBackgroundJobsStore.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import type { BackgroundJob } from '@/lib/schemas' +import { backgroundJobSchema } from '@/lib/schemas' + +interface BackgroundJobsState { + jobs: Record + addJob: (job: BackgroundJob) => void + removeJob: (id: string) => void + updateJob: (id: string, updates: Partial) => void + clearAllJobs: () => void + initializeJobs: (jobs: Record) => void +} + +export const BACKGROUND_JOBS_STORAGE_KEY = 'background-jobs' + +export const useBackgroundJobsStore = create()( + persist( + (set) => ({ + jobs: {}, + addJob: (job: BackgroundJob) => { + const validatedJob = backgroundJobSchema.parse(job) + set((state) => ({ + jobs: { ...state.jobs, [validatedJob.id]: validatedJob }, + })) + }, + removeJob: (id: string) => { + if (!id || id.trim().length === 0) { + throw new Error('Job ID is required') + } + set((state) => { + const { [id]: _removed, ...rest } = state.jobs + return { jobs: rest } + }) + }, + updateJob: (id: string, updates: Partial) => { + if (!id || id.trim().length === 0) { + throw new Error('Job ID is required') + } + set((state) => { + if (!(id in state.jobs)) { + return state + } + const updatedJob = { ...state.jobs[id], ...updates } + const validatedJob = backgroundJobSchema.parse(updatedJob) + return { + jobs: { + ...state.jobs, + [id]: validatedJob, + }, + } + }) + }, + clearAllJobs: () => set({ jobs: {} }), + initializeJobs: (jobs: Record) => { + // Validate all jobs before setting + const validatedJobs: Record = {} + for (const [id, job] of Object.entries(jobs)) { + validatedJobs[id] = backgroundJobSchema.parse(job) + } + set({ jobs: validatedJobs }) + }, + }), + { + name: BACKGROUND_JOBS_STORAGE_KEY, + }, + ), +) diff --git a/src/hooks/useStreamingChat.test.tsx b/src/hooks/useStreamingChat.test.tsx index 7883f2b..6af13ca 100644 --- a/src/hooks/useStreamingChat.test.tsx +++ b/src/hooks/useStreamingChat.test.tsx @@ -1140,6 +1140,123 @@ describe('useStreamingChat', () => { }) }) + describe('response.created events', () => { + it('should handle response.created with background: true option', async () => { + const { result } = renderHook(() => useStreamingChat()) + + const responseCreatedEvent = JSON.stringify({ + type: 'response.created', + response: { + id: 'resp_1234567890abcdef1234567890abcdef12345678', + }, + }) + + const mockReader = { + read: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`t:${responseCreatedEvent}\n`), + }) + .mockResolvedValueOnce({ done: true, value: undefined }), + } + + const mockResponse = createMockResponse({ + clone: vi.fn().mockReturnValue({ + body: { getReader: vi.fn().mockReturnValue(mockReader) }, + }), + }) + + act(() => { + result.current.handleResponse(mockResponse, { + background: true, + title: 'Test Background Job', + }) + }) + + await waitFor(() => { + expect(result.current.streamBuffer).toHaveLength(1) + }) + + expect(result.current.streamBuffer[0]).toEqual({ + type: 'assistant', + id: 'resp_1234567890abcdef1234567890abcdef12345678', + content: + "Background job started. Streaming the response in, but you can view it in the 'Background Jobs' if you leave.", + }) + }) + + it('should ignore response.created with background: false option', async () => { + const { result } = renderHook(() => useStreamingChat()) + + const responseCreatedEvent = JSON.stringify({ + type: 'response.created', + response: { + id: 'regular-job-123', + }, + }) + + const mockReader = { + read: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`t:${responseCreatedEvent}\n`), + }) + .mockResolvedValueOnce({ done: true, value: undefined }), + } + + const mockResponse = createMockResponse({ + clone: vi.fn().mockReturnValue({ + body: { getReader: vi.fn().mockReturnValue(mockReader) }, + }), + }) + + act(() => { + result.current.handleResponse(mockResponse, { background: false }) + }) + + await waitFor(() => { + expect(result.current.streamBuffer).toHaveLength(0) + }) + }) + + it('should ignore response.created with no options', async () => { + const { result } = renderHook(() => useStreamingChat()) + + const responseCreatedEvent = JSON.stringify({ + type: 'response.created', + response: { + id: 'no-options-job-123', + }, + }) + + const mockReader = { + read: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`t:${responseCreatedEvent}\n`), + }) + .mockResolvedValueOnce({ done: true, value: undefined }), + } + + const mockResponse = createMockResponse({ + clone: vi.fn().mockReturnValue({ + body: { getReader: vi.fn().mockReturnValue(mockReader) }, + }), + }) + + act(() => { + result.current.handleResponse(mockResponse) + }) + + await waitFor(() => { + expect(result.current.streamBuffer).toHaveLength(0) + }) + }) + }) + describe('cleanup', () => { it('should cleanup timeouts on unmount', async () => { const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout') diff --git a/src/hooks/useStreamingChat.ts b/src/hooks/useStreamingChat.ts index 1dbf9a7..5de9a06 100644 --- a/src/hooks/useStreamingChat.ts +++ b/src/hooks/useStreamingChat.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { generateMessageId } from '../mcp/client' +import { useBackgroundJobs } from './useBackgroundJobs' import type { AnnotatedFile } from '@/lib/utils/code-interpreter' import { stopStreamProcessing } from '@/lib/utils/streaming' import { getTimestamp } from '@/lib/utils/date' @@ -139,7 +140,10 @@ interface UseStreamingChatReturn { streaming: boolean timedOut: boolean requestId: string | null - handleResponse: (response: Response) => void + handleResponse: ( + response: Response, + options?: { background: true; title: string } | { background: false }, + ) => void handleError: (error: Error) => void addUserMessage: (content: string) => void cancelStream: () => void @@ -151,6 +155,7 @@ export function useStreamingChat(): UseStreamingChatReturn { const [streaming, setStreaming] = useState(false) const [timedOut, setTimedOut] = useState(false) const [requestId, setRequestId] = useState(null) + const { addJob: addBackgroundJob } = useBackgroundJobs() const streamUpdateTimeoutRef = useRef(null) const textBufferRef = useRef('') @@ -263,7 +268,10 @@ export function useStreamingChat(): UseStreamingChatReturn { }, []) const handleResponse = useCallback( - (response: Response) => { + ( + response: Response, + options?: { background: true; title: string } | { background: false }, + ) => { const xRequestId = response.headers.get('x-request-id') setRequestId(xRequestId) @@ -366,6 +374,31 @@ export function useStreamingChat(): UseStreamingChatReturn { return } + // Currently only used for background jobs + if (toolState.type === 'response.created') { + if (options?.background) { + const backgroundJob = { + id: toolState.response.id, + status: 'running' as const, + createdAt: getTimestamp(), + title: options.title, + } + + addBackgroundJob(backgroundJob) + + setStreamBuffer((prev: Array) => [ + ...prev, + { + type: 'assistant', + id: backgroundJob.id, + content: + "Background job started. Streaming the response in, but you can view it in the 'Background Jobs' if you leave.", + }, + ]) + } + return + } + if (toolState.type === 'reasoning_summary_delta') { setStreamBuffer((prev: Array) => { const last = prev[prev.length - 1] diff --git a/src/lib/__snapshots__/streaming.test.ts.snap b/src/lib/__snapshots__/streaming.test.ts.snap new file mode 100644 index 0000000..1ea53cc --- /dev/null +++ b/src/lib/__snapshots__/streaming.test.ts.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`streamText > emits stream_done when an error occurs 1`] = ` +"f:{"messageId":"msg-2"} +e:{"type":"error","message":"An unexpected error occurred."} +t:{"type":"stream_done"} +" +`; + +exports[`streamText > emits stream_done when the stream ends 1`] = ` +"f:{"messageId":"msg-1"} +0:"I'm glad you asked!" +0:" Here are a few universally nice things that" +0:" could have happened today:\\n\\n" +0:"- Someone smiled at a stranger, brightening" +0:" their day\\n" +0:"- A teacher helped a student understand a" +0:" difficult concept\\n" +0:"- A kind person paid for someone’s coffee" +0:" in line\\n" +0:"- A pet reunited with its owner at a local" +0:" animal shelter\\n" +0:"- A friend reached out just to say hello\\n\\n" +0:"Would you like to hear some real, uplifting" +0:" news from today?" +0:" Just let me know!" +t:{"type":"tool_call_completed","response":{}} +t:{"type":"stream_done"} +" +`; + +exports[`streamText > passes through response.created events 1`] = ` +"f:{"messageId":"msg-3"} +t:{"type":"response.created","response":{"id":"resp_123","model":"gpt-4","created":1234567890}} +t:{"type":"stream_done"} +" +`; diff --git a/src/lib/background-jobs.test.ts b/src/lib/background-jobs.test.ts new file mode 100644 index 0000000..93c5242 --- /dev/null +++ b/src/lib/background-jobs.test.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import OpenAI from 'openai' +import { BackgroundJobError, handleJobStatusRequest } from './background-jobs' + +// Mock OpenAI +vi.mock('openai') + +describe('handleJobStatusRequest', () => { + let mockOpenAI: { + responses: { + retrieve: ReturnType + } + } + + beforeEach(() => { + mockOpenAI = { + responses: { + retrieve: vi.fn(), + }, + } + + vi.mocked(OpenAI).mockImplementation(() => mockOpenAI as any) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('input validation', () => { + it('throws BackgroundJobError for missing request body', async () => { + await expect(handleJobStatusRequest({})).rejects.toThrow( + BackgroundJobError, + ) + + try { + await handleJobStatusRequest({}) + } catch (error) { + if (error instanceof BackgroundJobError) { + expect(error.statusCode).toBe(400) + } else { + throw new Error( + 'Expected BackgroundJobError, got ' + + (error?.constructor?.name ?? typeof error), + ) + } + } + }) + + it('throws BackgroundJobError for invalid id field', async () => { + await expect(handleJobStatusRequest({ id: '' })).rejects.toThrow( + BackgroundJobError, + ) + + try { + await handleJobStatusRequest({ id: '' }) + } catch (error) { + if (error instanceof BackgroundJobError) { + expect(error.statusCode).toBe(400) + } else { + throw new Error( + 'Expected BackgroundJobError, got ' + + (error?.constructor?.name ?? typeof error), + ) + } + } + }) + + it('accepts valid request with id', async () => { + mockOpenAI.responses.retrieve.mockResolvedValue({ + status: 'running', + }) + + const result = await handleJobStatusRequest({ + id: 'resp_1234567890abcdef1234567890abcdef12345678', + }) + + expect(result.status).toBe('running') + expect(mockOpenAI.responses.retrieve).toHaveBeenCalledWith( + 'resp_1234567890abcdef1234567890abcdef12345678', + ) + }) + }) + + describe('OpenAI integration', () => { + it('returns completed status when OpenAI response is completed', async () => { + mockOpenAI.responses.retrieve.mockResolvedValue({ + status: 'completed', + }) + + const result = await handleJobStatusRequest({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef12', + }) + + expect(result.status).toBe('completed') + expect(result).toHaveProperty('completedAt') + expect(typeof result.completedAt).toBe('string') + }) + + it('returns failed status when OpenAI response is failed', async () => { + mockOpenAI.responses.retrieve.mockResolvedValue({ + status: 'failed', + }) + + const result = await handleJobStatusRequest({ + id: 'resp_fedcba0987654321fedcba0987654321fedcba09', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Background job failed') + }) + + it('returns running status when OpenAI response is in progress', async () => { + mockOpenAI.responses.retrieve.mockResolvedValue({ + status: 'in_progress', + }) + + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result.status).toBe('running') + }) + + it('returns running status for any non-completed/failed status', async () => { + mockOpenAI.responses.retrieve.mockResolvedValue({ + status: 'queued', + }) + + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result.status).toBe('running') + }) + }) + + describe('OpenAI error handling', () => { + it('handles 404 errors with user-friendly message', async () => { + const apiError = new OpenAI.APIError( + 404, + { message: 'Not found' }, + 'Not found', + {}, + ) + apiError.status = 404 + mockOpenAI.responses.retrieve.mockRejectedValue(apiError) + + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Background job not found') + }) + + it('handles 401 errors with authentication message', async () => { + const apiError = new OpenAI.APIError( + 401, + { message: 'Unauthorized' }, + 'Unauthorized', + {}, + ) + apiError.status = 401 + mockOpenAI.responses.retrieve.mockRejectedValue(apiError) + + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Authentication failed') + }) + + it('handles 403 errors with access denied message', async () => { + const apiError = new OpenAI.APIError( + 403, + { message: 'Forbidden' }, + 'Forbidden', + {}, + ) + apiError.status = 403 + mockOpenAI.responses.retrieve.mockRejectedValue(apiError) + + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Access denied') + }) + + it('handles 429 rate limit errors', async () => { + const apiError = new OpenAI.APIError( + 429, + { message: 'Rate limit exceeded' }, + 'Rate limit exceeded', + {}, + ) + apiError.status = 429 + mockOpenAI.responses.retrieve.mockRejectedValue(apiError) + + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Rate limit exceeded') + }) + + it('handles generic API errors with default message', async () => { + const apiError = new OpenAI.APIError( + 502, + { message: 'Bad gateway' }, + 'Bad gateway', + {}, + ) + mockOpenAI.responses.retrieve.mockRejectedValue(apiError) + + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Failed to check background job status') + }) + + it('handles API errors without status code', async () => { + const apiError = new OpenAI.APIError( + undefined, + { message: 'Unknown error' }, + 'Unknown error', + {}, + ) + apiError.status = undefined + mockOpenAI.responses.retrieve.mockRejectedValue(apiError) + + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result.status).toBe('failed') + expect(result.error).toBe('Failed to check background job status') + }) + }) + + describe('network and generic error handling', () => { + it('throws BackgroundJobError for network errors', async () => { + const networkError = new Error('Network connection failed') + mockOpenAI.responses.retrieve.mockRejectedValue(networkError) + + await expect( + handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }), + ).rejects.toThrow(BackgroundJobError) + + try { + await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + } catch (error) { + if (error instanceof BackgroundJobError) { + expect(error.statusCode).toBe(500) + expect(error.message).toBe('Internal server error') + } else { + throw new Error( + 'Expected BackgroundJobError, got ' + + (error?.constructor?.name ?? typeof error), + ) + } + } + }) + + it('throws BackgroundJobError for malformed request body', async () => { + // Simulate malformed JSON by passing invalid data that would cause parsing error + await expect(handleJobStatusRequest('{ invalid json')).rejects.toThrow( + BackgroundJobError, + ) + + try { + await handleJobStatusRequest('{ invalid json') + } catch (error) { + if (error instanceof BackgroundJobError) { + expect(error.statusCode).toBe(400) + } else { + throw new Error( + 'Expected BackgroundJobError, got ' + + (error?.constructor?.name ?? typeof error), + ) + } + } + }) + }) + + describe('response format validation', () => { + it('includes completedAt timestamp for completed jobs', async () => { + mockOpenAI.responses.retrieve.mockResolvedValue({ + status: 'completed', + }) + + const beforeTime = new Date().toISOString() + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) + + expect(result).toHaveProperty('completedAt') + expect(new Date(result.completedAt!).getTime()).toBeGreaterThanOrEqual( + new Date(beforeTime).getTime(), + ) + }) + }) +}) diff --git a/src/lib/background-jobs.ts b/src/lib/background-jobs.ts new file mode 100644 index 0000000..a947706 --- /dev/null +++ b/src/lib/background-jobs.ts @@ -0,0 +1,88 @@ +import OpenAI from 'openai' +import { jobStatusRequestSchema } from './schemas' + +export interface BackgroundJobStatusResponse { + status: 'running' | 'completed' | 'failed' + response?: string + error?: string + completedAt?: string +} + +export class BackgroundJobError extends Error { + constructor( + public statusCode: number, + message: string, + ) { + super(message) + this.name = 'BackgroundJobError' + } +} + +export async function handleJobStatusRequest( + requestBody: unknown, +): Promise { + const result = jobStatusRequestSchema.safeParse(requestBody) + + if (!result.success) { + throw new BackgroundJobError( + 400, + JSON.stringify({ error: result.error.errors }), + ) + } + + const { id } = result.data + const client = new OpenAI() + + try { + const response = await client.responses.retrieve(id) + + // Return response based on status + if (response.status === 'completed') { + return { + status: 'completed', + completedAt: new Date().toISOString(), + } + } else if (response.status === 'failed') { + return { + status: 'failed', + error: 'Background job failed', + } + } else { + // Still running, but may have partial content + return { + status: 'running', + } + } + } catch (error) { + console.error('Error retrieving OpenAI response:', error) + + if (error instanceof OpenAI.APIError) { + const statusCode = error.status || 500 + let clientMessage = 'Failed to check background job status' + + switch (statusCode) { + case 404: + clientMessage = 'Background job not found' + break + case 401: + clientMessage = 'Authentication failed' + break + case 403: + clientMessage = 'Access denied' + break + case 429: + clientMessage = 'Rate limit exceeded' + break + } + + // Return 200 status with error in body for API errors + return { + status: 'failed', + error: clientMessage, + } + } + + // Re-throw non-API errors as 500 + throw new BackgroundJobError(500, 'Internal server error') + } +} diff --git a/src/lib/background-supported-models.test.ts b/src/lib/background-supported-models.test.ts new file mode 100644 index 0000000..6ac0a23 --- /dev/null +++ b/src/lib/background-supported-models.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { isBackgroundSupported } from './background-supported-models' + +describe('isBackgroundSupported', () => { + it('returns true for exact model matches', () => { + expect(isBackgroundSupported('gpt-4o')).toBe(true) + expect(isBackgroundSupported('gpt-4.1')).toBe(true) + expect(isBackgroundSupported('o3')).toBe(true) + }) + + it('returns true for models that start with supported prefixes', () => { + expect(isBackgroundSupported('gpt-4o-2024-05-13')).toBe(true) + expect(isBackgroundSupported('gpt-4.1-preview')).toBe(true) + expect(isBackgroundSupported('o3-mini')).toBe(true) + }) + + it('is case insensitive', () => { + expect(isBackgroundSupported('GPT-4O')).toBe(true) + expect(isBackgroundSupported('Gpt-4.1')).toBe(true) + expect(isBackgroundSupported('O3')).toBe(true) + }) + + it('returns false for unsupported models', () => { + expect(isBackgroundSupported('gpt-3.5-turbo')).toBe(false) + expect(isBackgroundSupported('claude-3-sonnet')).toBe(false) + expect(isBackgroundSupported('llama-2')).toBe(false) + expect(isBackgroundSupported('random-model')).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isBackgroundSupported('')).toBe(false) + }) + + it('handles partial matches correctly', () => { + expect(isBackgroundSupported('gpt-4')).toBe(false) + expect(isBackgroundSupported('gpt')).toBe(false) + expect(isBackgroundSupported('o')).toBe(false) + }) +}) diff --git a/src/lib/background-supported-models.ts b/src/lib/background-supported-models.ts new file mode 100644 index 0000000..8872169 --- /dev/null +++ b/src/lib/background-supported-models.ts @@ -0,0 +1,14 @@ +const BACKGROUND_SUPPORTED_MODELS = [ + 'gpt-4o', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'o3', + 'o4-mini', +] + +export function isBackgroundSupported(model: string): boolean { + return BACKGROUND_SUPPORTED_MODELS.some((m) => + model.toLowerCase().startsWith(m.toLowerCase()), + ) +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index b5ece71..6405820 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -69,7 +69,31 @@ export const chatRequestSchema = z.object({ model: z.string(), userId: z.string(), codeInterpreter: z.boolean().default(false), - webSearch: z.boolean().default(false), // NEW: enable web search + webSearch: z.boolean().default(false), + background: z.boolean().optional().default(false), + store: z.boolean().optional().default(false), +}) + +const id = z + .string() + .regex( + /^resp_[a-f0-9]+$/, + 'Background job ID must be in the format resp_<40-char lowercase hex>', + ) + +export const jobStatusRequestSchema = z.object({ + id, +}) + +// Background job schema +export const backgroundJobSchema = z.object({ + id, + status: z.enum(['running', 'completed', 'failed']), + createdAt: z.string(), + completedAt: z.string().optional(), + title: z.string().optional(), + response: z.string().optional(), + error: z.string().optional(), }) // Disconnect request schema @@ -132,3 +156,4 @@ export const containerFileQuerySchema = z.object({ export type ContainerFileQuery = z.infer export type ChatRequest = z.infer +export type BackgroundJob = z.infer diff --git a/src/lib/streaming.test.ts b/src/lib/streaming.test.ts index 2cb193e..5ec1936 100644 --- a/src/lib/streaming.test.ts +++ b/src/lib/streaming.test.ts @@ -1,7 +1,15 @@ -import { describe, expect, it } from 'vitest' -import { stopStreamProcessing } from './utils/streaming' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getMessageId, stopStreamProcessing } from './utils/streaming' import { streamText } from './streaming' +vi.mock('./utils/streaming', async () => { + const actual = await vi.importActual('./utils/streaming') + return { + ...actual, + getMessageId: vi.fn(), + } +}) + function iterableFromArray(arr: Array): AsyncIterable { return { [Symbol.asyncIterator]() { @@ -17,6 +25,19 @@ function iterableFromArray(arr: Array): AsyncIterable { } } +async function readStreamToString( + reader: ReadableStreamDefaultReader, +): Promise { + let result = '' + let done = false + while (!done) { + const { value, done: d } = await reader.read() + if (value) result += new TextDecoder().decode(value) + done = d + } + return result +} + describe('stopStreamProcessing', () => { it('replaces response body with an empty closed stream', async () => { const originalStream = new ReadableStream({ @@ -70,7 +91,35 @@ describe('stopStreamProcessing', () => { }) }) +describe('getMessageId', () => { + it('returns a unique message ID with msg prefix', async () => { + const actual = await vi.importActual('./utils/streaming') + const id1 = actual.getMessageId() + const id2 = actual.getMessageId() + + expect(id1).toMatch( + /^msg-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ) + expect(id2).toMatch( + /^msg-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ) + expect(id1).not.toBe(id2) + }) +}) + describe('streamText', () => { + let messageCounter = 0 + + beforeEach(() => { + vi.mocked(getMessageId).mockImplementation(() => { + messageCounter++ + return `msg-${messageCounter}` + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) it('emits stream_done when the stream ends', async () => { const chunks = [ { type: 'response.output_text.delta', delta: "I'm glad you asked!" }, @@ -127,14 +176,8 @@ describe('streamText', () => { ] const response = streamText(iterableFromArray(chunks)) const reader = response.body!.getReader() - let result = '' - let done = false - while (!done) { - const { value, done: d } = await reader.read() - if (value) result += new TextDecoder().decode(value) - done = d - } - expect(result).toMatch(/t:{"type":"stream_done"}/) + const result = await readStreamToString(reader) + expect(result).toMatchSnapshot() }) it('emits stream_done when an error occurs', async () => { @@ -153,16 +196,9 @@ describe('streamText', () => { } const response = streamText(errorIterable) const reader = response.body!.getReader() - let result = '' - let done = false - while (!done) { - const { value, done: d } = await reader.read() - if (value) result += new TextDecoder().decode(value) - done = d - } + const result = await readStreamToString(reader) - expect(result).toMatch(/t:{"type":"stream_done"}/) - expect(result).toMatch(/e:{"type":"error".*}/) + expect(result).toMatchSnapshot() expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error during streamed response:', expect.any(Error), @@ -173,4 +209,22 @@ describe('streamText', () => { consoleErrorSpy.mockRestore() }) + + it('passes through response.created events', async () => { + const chunks = [ + { + type: 'response.created', + response: { + id: 'resp_123', + model: 'gpt-4', + created: 1234567890, + }, + }, + ] + const response = streamText(iterableFromArray(chunks)) + const reader = response.body!.getReader() + const result = await readStreamToString(reader) + + expect(result).toMatchSnapshot() + }) }) diff --git a/src/lib/streaming.ts b/src/lib/streaming.ts index c4cb00a..aea6bac 100644 --- a/src/lib/streaming.ts +++ b/src/lib/streaming.ts @@ -1,4 +1,5 @@ import { APIError } from 'openai' +import { getMessageId } from './utils/streaming' // Event chunk for stream completion const STREAM_DONE_CHUNK = new TextEncoder().encode( @@ -10,7 +11,7 @@ export function streamText( onMessageId?: (messageId: string) => void, ): Response { const encoder = new TextEncoder() - const messageId = `msg-${Math.random().toString(36).slice(2)}` + const messageId = getMessageId() const stream = new ReadableStream({ async start(controller) { @@ -336,8 +337,14 @@ export function streamText( ), ) break - // Web search tool events + + case 'response.created': + // Pass through response.created events so background jobs can extract response ID + controller.enqueue(encoder.encode(`t:${JSON.stringify(chunk)}\n`)) + break + default: + // Web search tool events if ( chunk.type && chunk.type.startsWith('response.web_search_call.') diff --git a/src/lib/utils/streaming.ts b/src/lib/utils/streaming.ts index 856e5d6..e41daf2 100644 --- a/src/lib/utils/streaming.ts +++ b/src/lib/utils/streaming.ts @@ -15,3 +15,12 @@ export function stopStreamProcessing(response: Response) { value: emptyStream, }) } + +/** + * Generates a unique message ID for use in streaming contexts. + * + * @returns A unique message ID string. + */ +export function getMessageId(): string { + return `msg-${crypto.randomUUID()}` +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 03ebca2..4ab3010 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as IndexRouteImport } from './routes/index' import { ServerRoute as ApiModelsServerRouteImport } from './routes/api/models' import { ServerRoute as ApiContainerFileServerRouteImport } from './routes/api/container-file' import { ServerRoute as ApiChatServerRouteImport } from './routes/api/chat' +import { ServerRoute as ApiBackgroundJobsServerRouteImport } from './routes/api/background-jobs' const rootServerRouteImport = createServerRootRoute() @@ -38,6 +39,11 @@ const ApiChatServerRoute = ApiChatServerRouteImport.update({ path: '/api/chat', getParentRoute: () => rootServerRouteImport, } as any) +const ApiBackgroundJobsServerRoute = ApiBackgroundJobsServerRouteImport.update({ + id: '/api/background-jobs', + path: '/api/background-jobs', + getParentRoute: () => rootServerRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -61,30 +67,47 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute } export interface FileServerRoutesByFullPath { + '/api/background-jobs': typeof ApiBackgroundJobsServerRoute '/api/chat': typeof ApiChatServerRoute '/api/container-file': typeof ApiContainerFileServerRoute '/api/models': typeof ApiModelsServerRoute } export interface FileServerRoutesByTo { + '/api/background-jobs': typeof ApiBackgroundJobsServerRoute '/api/chat': typeof ApiChatServerRoute '/api/container-file': typeof ApiContainerFileServerRoute '/api/models': typeof ApiModelsServerRoute } export interface FileServerRoutesById { __root__: typeof rootServerRouteImport + '/api/background-jobs': typeof ApiBackgroundJobsServerRoute '/api/chat': typeof ApiChatServerRoute '/api/container-file': typeof ApiContainerFileServerRoute '/api/models': typeof ApiModelsServerRoute } export interface FileServerRouteTypes { fileServerRoutesByFullPath: FileServerRoutesByFullPath - fullPaths: '/api/chat' | '/api/container-file' | '/api/models' + fullPaths: + | '/api/background-jobs' + | '/api/chat' + | '/api/container-file' + | '/api/models' fileServerRoutesByTo: FileServerRoutesByTo - to: '/api/chat' | '/api/container-file' | '/api/models' - id: '__root__' | '/api/chat' | '/api/container-file' | '/api/models' + to: + | '/api/background-jobs' + | '/api/chat' + | '/api/container-file' + | '/api/models' + id: + | '__root__' + | '/api/background-jobs' + | '/api/chat' + | '/api/container-file' + | '/api/models' fileServerRoutesById: FileServerRoutesById } export interface RootServerRouteChildren { + ApiBackgroundJobsServerRoute: typeof ApiBackgroundJobsServerRoute ApiChatServerRoute: typeof ApiChatServerRoute ApiContainerFileServerRoute: typeof ApiContainerFileServerRoute ApiModelsServerRoute: typeof ApiModelsServerRoute @@ -124,6 +147,13 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: typeof ApiChatServerRouteImport parentRoute: typeof rootServerRouteImport } + '/api/background-jobs': { + id: '/api/background-jobs' + path: '/api/background-jobs' + fullPath: '/api/background-jobs' + preLoaderRoute: typeof ApiBackgroundJobsServerRouteImport + parentRoute: typeof rootServerRouteImport + } } } @@ -134,6 +164,7 @@ export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() const rootServerRouteChildren: RootServerRouteChildren = { + ApiBackgroundJobsServerRoute: ApiBackgroundJobsServerRoute, ApiChatServerRoute: ApiChatServerRoute, ApiContainerFileServerRoute: ApiContainerFileServerRoute, ApiModelsServerRoute: ApiModelsServerRoute, diff --git a/src/routes/api/background-jobs.ts b/src/routes/api/background-jobs.ts new file mode 100644 index 0000000..059f73d --- /dev/null +++ b/src/routes/api/background-jobs.ts @@ -0,0 +1,109 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import OpenAI from 'openai' +import { + BackgroundJobError, + handleJobStatusRequest, +} from '@/lib/background-jobs' +import { streamText } from '@/lib/streaming' + +export const ServerRoute = createServerFileRoute( + '/api/background-jobs', +).methods({ + async POST({ request }) { + try { + const body = await request.json() + const result = await handleJobStatusRequest(body) + + return new Response(JSON.stringify(result), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + console.error('Error in background jobs route:', error) + + if (error instanceof BackgroundJobError) { + const responseBody = error.message.startsWith('{') + ? error.message // Already JSON string for validation errors + : JSON.stringify({ error: error.message }) + + return new Response(responseBody, { + status: error.statusCode, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + async GET({ request }) { + const url = new URL(request.url) + const backgroundJobId = url.searchParams.get('id') + + if (!backgroundJobId) { + return new Response( + JSON.stringify({ error: 'Background job ID is required' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const client = new OpenAI() + + try { + const response = await client.responses.retrieve(backgroundJobId, { + stream: true, + }) + + return streamText(response) + } catch (error) { + console.error('Error retrieving OpenAI response:', error) + + if (error instanceof OpenAI.APIError) { + const statusCode = error.status || 500 + let clientMessage = 'Failed to check background job status' + + switch (statusCode) { + case 404: + clientMessage = 'Background job not found' + break + case 401: + clientMessage = 'Authentication failed' + break + case 403: + clientMessage = 'Access denied' + break + case 429: + clientMessage = 'Rate limit exceeded' + break + } + + return new Response( + JSON.stringify({ + status: 'failed', + error: clientMessage, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + return new Response( + JSON.stringify({ + status: 'failed', + error: 'Internal server error', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, +}) diff --git a/src/routes/api/chat.ts b/src/routes/api/chat.ts index 791d475..087c684 100644 --- a/src/routes/api/chat.ts +++ b/src/routes/api/chat.ts @@ -5,7 +5,7 @@ import { streamText } from '../../lib/streaming' import { getSystemPrompt, isCodeInterpreterSupported, - isWebSearchSupported, // NEW + isWebSearchSupported, } from '../../lib/utils/prompting' import type { Tool } from 'openai/resources/responses/responses.mjs' @@ -40,8 +40,15 @@ export const ServerRoute = createServerFileRoute('/api/chat').methods({ }) } - const { messages, servers, model, userId, codeInterpreter, webSearch } = - result.data + const { + messages, + servers, + model, + userId, + codeInterpreter, + webSearch, + background, + } = result.data if (messages.length === 0) { return new Response(JSON.stringify({ error: 'No messages provided' }), { @@ -150,6 +157,9 @@ export const ServerRoute = createServerFileRoute('/api/chat').methods({ tools, input: conversationHistory, stream: true, + background, + // Store if background or explicitly requested + store: background, user: userId, ...(model.startsWith('o3') || model.startsWith('o4') ? {