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 (
+
+
+
+
+ {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')
? {