From ce95bad5fe90b92bef7f57f1d91acab9341f3fd1 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 22 Jul 2025 22:19:21 -0400 Subject: [PATCH 01/19] feat: implement background job status handling with API integration --- src/hooks/useBackgroundJobStatus.test.tsx | 228 ++++++++++++++++++++ src/hooks/useBackgroundJobStatus.ts | 69 ++++++ src/lib/background-jobs.test.ts | 250 ++++++++++++++++++++++ src/lib/background-jobs.ts | 88 ++++++++ src/lib/schemas.ts | 22 +- src/routeTree.gen.ts | 37 +++- src/routes/api/background-jobs.ts | 36 ++++ 7 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useBackgroundJobStatus.test.tsx create mode 100644 src/hooks/useBackgroundJobStatus.ts create mode 100644 src/lib/background-jobs.test.ts create mode 100644 src/lib/background-jobs.ts create mode 100644 src/routes/api/background-jobs.ts diff --git a/src/hooks/useBackgroundJobStatus.test.tsx b/src/hooks/useBackgroundJobStatus.test.tsx new file mode 100644 index 0000000..f474876 --- /dev/null +++ b/src/hooks/useBackgroundJobStatus.test.tsx @@ -0,0 +1,228 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } 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', () => { + const job = { + id: 'job-4', + status: 'failed' 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 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-5', + 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-6', + 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-6' }), + }) + }) + }) + + it('calls fetch for HTTP error responses', async () => { + const job = { + id: 'job-7', + 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-7' }), + }) + }) + }) + }) +}) diff --git a/src/hooks/useBackgroundJobStatus.ts b/src/hooks/useBackgroundJobStatus.ts new file mode 100644 index 0000000..05627af --- /dev/null +++ b/src/hooks/useBackgroundJobStatus.ts @@ -0,0 +1,69 @@ +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', + 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/lib/background-jobs.test.ts b/src/lib/background-jobs.test.ts new file mode 100644 index 0000000..fba7713 --- /dev/null +++ b/src/lib/background-jobs.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import OpenAI from 'openai' +import { handleJobStatusRequest, BackgroundJobError } 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) { + expect(error).toBeInstanceOf(BackgroundJobError) + expect(error.statusCode).toBe(400) + } + }) + + it('throws BackgroundJobError for invalid id field', async () => { + await expect(handleJobStatusRequest({ id: '' })).rejects.toThrow(BackgroundJobError) + + try { + await handleJobStatusRequest({ id: '' }) + } catch (error) { + expect(error).toBeInstanceOf(BackgroundJobError) + expect(error.statusCode).toBe(400) + } + }) + + it('accepts valid request with id', async () => { + mockOpenAI.responses.retrieve.mockResolvedValue({ + status: 'running', + }) + + const result = await handleJobStatusRequest({ id: 'valid-job-id' }) + + expect(result.status).toBe('running') + expect(mockOpenAI.responses.retrieve).toHaveBeenCalledWith('valid-job-id') + }) + }) + + describe('OpenAI integration', () => { + it('returns completed status when OpenAI response is completed', async () => { + mockOpenAI.responses.retrieve.mockResolvedValue({ + status: 'completed', + }) + + const result = await handleJobStatusRequest({ id: 'completed-job' }) + + 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: 'failed-job' }) + + 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: 'running-job' }) + + 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: 'queued-job' }) + + 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: 'missing-job' }) + + 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: 'unauthorized-job' }) + + 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: 'forbidden-job' }) + + 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: 'rate-limited-job' }) + + 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: 'server-error-job' }) + + 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: 'unknown-error-job' }) + + 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: 'network-error-job' })).rejects.toThrow(BackgroundJobError) + + try { + await handleJobStatusRequest({ id: 'network-error-job' }) + } catch (error) { + expect(error).toBeInstanceOf(BackgroundJobError) + expect(error.statusCode).toBe(500) + expect(error.message).toBe('Internal server 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) { + expect(error).toBeInstanceOf(BackgroundJobError) + expect(error.statusCode).toBe(400) + } + }) + }) + + 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: 'timestamp-test' }) + + expect(result).toHaveProperty('completedAt') + expect(new Date(result.completedAt!).getTime()).toBeGreaterThanOrEqual( + new Date(beforeTime).getTime() + ) + }) + }) +}) \ No newline at end of file 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/schemas.ts b/src/lib/schemas.ts index b5ece71..e72e722 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -69,7 +69,26 @@ 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().min(1, 'Background job ID is required') + +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 +151,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/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..e11fbfb --- /dev/null +++ b/src/routes/api/background-jobs.ts @@ -0,0 +1,36 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' +import { handleJobStatusRequest, BackgroundJobError } from '@/lib/background-jobs' + +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' }, + }) + } + }, +}) From 91f44b37c3e79fae0d5819b76a74431cc8ed8376 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 22 Jul 2025 23:01:39 -0400 Subject: [PATCH 02/19] feat: add BackgroundJobItem component with job status handling and actions --- .storybook/preview.tsx | 18 +- src/components/BackgroundJobItem.stories.tsx | 85 ++++++++ src/components/BackgroundJobItem.tsx | 192 +++++++++++++++++++ 3 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 src/components/BackgroundJobItem.stories.tsx create mode 100644 src/components/BackgroundJobItem.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 0a42cc1..a3d228a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,6 +1,16 @@ import type { Preview } from '@storybook/react-vite' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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/src/components/BackgroundJobItem.stories.tsx b/src/components/BackgroundJobItem.stories.tsx new file mode 100644 index 0000000..6a820f3 --- /dev/null +++ b/src/components/BackgroundJobItem.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { BackgroundJobItem } from './BackgroundJobItem' +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..154b9dc --- /dev/null +++ b/src/components/BackgroundJobItem.tsx @@ -0,0 +1,192 @@ +import { Button } from './ui/button' +import { Clock, Play, X, Trash2 } from 'lucide-react' +import { useBackgroundJobStatus } from '@/hooks/useBackgroundJobStatus' +import type { BackgroundJob } from '@/lib/schemas' +import { useEffect } from 'react' + +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) { + console.log('Polling data received:', statusData) + console.log('Current job:', job) + + const needsUpdate = + statusData.status !== job.status || + statusData.response !== job.response || + statusData.error !== job.error || + statusData.completedAt !== job.completedAt + + if (needsUpdate) { + console.log('Updating job with:', { + status: statusData.status, + response: statusData.response, + error: statusData.error, + completedAt: statusData.completedAt, + }) + + 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} +
+ )} + + +
+ ) +} From 7978975179436f1d11e555c95299a3f1713195f4 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 22 Jul 2025 23:23:55 -0400 Subject: [PATCH 03/19] feat: implement useBackgroundJobs hook for job management with local storage --- src/hooks/useBackgroundJobs.test.tsx | 368 +++++++++++++++++++++++++++ src/hooks/useBackgroundJobs.ts | 80 ++++++ 2 files changed, 448 insertions(+) create mode 100644 src/hooks/useBackgroundJobs.test.tsx create mode 100644 src/hooks/useBackgroundJobs.ts diff --git a/src/hooks/useBackgroundJobs.test.tsx b/src/hooks/useBackgroundJobs.test.tsx new file mode 100644 index 0000000..e3d62c0 --- /dev/null +++ b/src/hooks/useBackgroundJobs.test.tsx @@ -0,0 +1,368 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, beforeEach } from 'vitest' +import { useBackgroundJobs } from './useBackgroundJobs' +import type { BackgroundJob } from '../lib/schemas' + +describe('useBackgroundJobs', () => { + const STORAGE_KEY = 'background-jobs' + + beforeEach(() => { + localStorage.clear() + }) + + const createMockJob = ( + overrides: Partial = {}, + ): BackgroundJob => ({ + id: 'test-job-1', + status: 'running', + createdAt: '2025-01-01T00:00:00Z', + title: 'Test Job', + ...overrides, + }) + + describe('initialization and persistence', () => { + 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: 'job1' }) + const job2 = createMockJob({ id: 'job2', status: 'completed' }) + const storedData = { job1, job2 } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(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: 'job1', title: 'Job 1' }) + const job2 = createMockJob({ id: 'job2', 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: 'job1' }) + const job2 = createMockJob({ id: 'job2' }) + + act(() => { + result.current.addJob(job1) + result.current.addJob(job2) + }) + + act(() => { + result.current.removeJob('job1') + }) + + expect(result.current.jobs).toHaveLength(1) + expect(result.current.jobs).toContainEqual(job2) + expect(result.current.getJobById('job1')).toBeUndefined() + expect(result.current.getJobById('job2')).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: 'existing' }) + + 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: 'job1' }) + const job2 = createMockJob({ id: 'job2' }) + + 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(STORAGE_KEY) || '{}') + expect(storedData[job.id]).toEqual(finalJob) + }) + + it('should handle concurrent job operations', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const runningJob = createMockJob({ + id: 'concurrent-running-xyz', + status: 'running', + title: 'Running Job', + }) + const completedJob = createMockJob({ + id: 'concurrent-completed-abc', + status: 'completed', + title: 'Completed Job', + }) + const failedJob = createMockJob({ + id: 'concurrent-failed-def', + status: 'failed', + title: 'Failed Job', + }) + + expect(result.current.jobs).toHaveLength(0) + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + + 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('concurrent-completed-abc') + }) + + act(() => { + result.current.updateJob('concurrent-running-xyz', { + status: 'completed', + }) + }) + + expect(result.current.jobs).toHaveLength(2) + expect(result.current.getJobById('concurrent-running-xyz')?.status).toBe( + 'completed', + ) + expect( + result.current.getJobById('concurrent-completed-abc'), + ).toBeUndefined() + expect(result.current.getJobById('concurrent-failed-def')).toEqual( + failedJob, + ) + }) + + it('should maintain data integrity across browser sessions', () => { + const job1 = createMockJob({ + id: 'browser-session-alpha', + title: 'Session 1 Job', + }) + const job2 = createMockJob({ + id: 'browser-session-beta', + title: 'Session 2 Job', + }) + + let firstSessionResult = renderHook(() => useBackgroundJobs()) + + expect(firstSessionResult.result.current.jobs).toHaveLength(0) + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + + 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() + + let secondSessionResult = renderHook(() => useBackgroundJobs()) + expect(secondSessionResult.result.current.jobs).toHaveLength(2) + expect( + secondSessionResult.result.current.getJobById('browser-session-alpha'), + ).toEqual(job1) + expect( + secondSessionResult.result.current.getJobById('browser-session-beta'), + ).toEqual(job2) + + act(() => { + secondSessionResult.result.current.updateJob('browser-session-alpha', { + status: 'completed', + }) + }) + + expect( + secondSessionResult.result.current.getJobById('browser-session-alpha') + ?.status, + ).toBe('completed') + + act(() => { + secondSessionResult.result.current.removeJob('browser-session-beta') + }) + + expect(secondSessionResult.result.current.jobs).toHaveLength(1) + expect( + secondSessionResult.result.current.getJobById('browser-session-beta'), + ).toBeUndefined() + + secondSessionResult.unmount() + + let thirdSessionResult = renderHook(() => useBackgroundJobs()) + expect(thirdSessionResult.result.current.jobs).toHaveLength(1) + + const persistedJob = thirdSessionResult.result.current.getJobById( + 'browser-session-alpha', + ) + expect(persistedJob).toBeDefined() + expect(persistedJob?.status).toBe('completed') + expect(persistedJob?.title).toBe('Session 1 Job') // Should preserve other fields + expect( + thirdSessionResult.result.current.getJobById('browser-session-beta'), + ).toBeUndefined() + thirdSessionResult.unmount() + }) + }) +}) diff --git a/src/hooks/useBackgroundJobs.ts b/src/hooks/useBackgroundJobs.ts new file mode 100644 index 0000000..98a7a44 --- /dev/null +++ b/src/hooks/useBackgroundJobs.ts @@ -0,0 +1,80 @@ +import { useCallback, useMemo } from 'react' +import { useLocalStorage } from './useLocalStorage' +import type { BackgroundJob } from '../lib/schemas' + +const STORAGE_KEY = 'background-jobs' + +interface UseBackgroundJobsReturn { + jobs: BackgroundJob[] + 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, setJobsMap] = useLocalStorage>( + STORAGE_KEY, + {}, + ) + + const jobs = useMemo(() => Object.values(jobsMap).filter(Boolean), [jobsMap]) + + const addJob = useCallback( + (job: BackgroundJob) => { + setJobsMap((prev) => ({ + ...prev, + [job.id]: job, + })) + }, + [setJobsMap], + ) + + const removeJob = useCallback( + (id: string) => { + setJobsMap((prev) => { + const { [id]: removed, ...rest } = prev + return rest + }) + }, + [setJobsMap], + ) + + const updateJob = useCallback( + (id: string, updates: Partial) => { + setJobsMap((prev) => { + if (!prev[id]) { + return prev + } + return { + ...prev, + [id]: { ...prev[id], ...updates }, + } + }) + }, + [setJobsMap], + ) + + const getJobById = useCallback( + (id: string) => { + return jobsMap[id] + }, + [jobsMap], + ) + + const clearAllJobs = useCallback(() => { + setJobsMap({}) + }, [setJobsMap]) + + return { + jobs, + jobsMap, + addJob, + removeJob, + updateJob, + getJobById, + clearAllJobs, + } +} From 56e7c49274f4478435b21f6f8aad8fc5f71daf55 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 22 Jul 2025 23:25:38 -0400 Subject: [PATCH 04/19] feat: add BackgroundJobsSidebar and Sidebar components for background job management --- src/components/BackgroundJobsSidebar.tsx | 65 ++++++++++++++++++++++++ src/components/ui/sidebar.tsx | 56 ++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/components/BackgroundJobsSidebar.tsx create mode 100644 src/components/ui/sidebar.tsx diff --git a/src/components/BackgroundJobsSidebar.tsx b/src/components/BackgroundJobsSidebar.tsx new file mode 100644 index 0000000..62d4fda --- /dev/null +++ b/src/components/BackgroundJobsSidebar.tsx @@ -0,0 +1,65 @@ +import { Sidebar } from './ui/sidebar' +import { useHasMounted } from '@/hooks/useHasMounted' +import { useBackgroundJobs } from '@/hooks/useBackgroundJobs' +import { Clock } from 'lucide-react' +import { BackgroundJobItem } from './BackgroundJobItem' + +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/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..b2dfa45 --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' +import { Button } from './button' +import { X } from 'lucide-react' + +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} +
+
+ + ) +} \ No newline at end of file From 38935371dd11a826edc6d33eee94932e3289e8c8 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Tue, 22 Jul 2025 23:31:48 -0400 Subject: [PATCH 05/19] feat: add BackgroundToggle component with state management and tooltip handling --- src/components/BackgroundToggle.stories.tsx | 98 +++++++++++++++++++++ src/components/BackgroundToggle.tsx | 40 +++++++++ src/lib/background-supported-models.test.ts | 39 ++++++++ src/lib/background-supported-models.ts | 14 +++ 4 files changed, 191 insertions(+) create mode 100644 src/components/BackgroundToggle.stories.tsx create mode 100644 src/components/BackgroundToggle.tsx create mode 100644 src/lib/background-supported-models.test.ts create mode 100644 src/lib/background-supported-models.ts diff --git a/src/components/BackgroundToggle.stories.tsx b/src/components/BackgroundToggle.stories.tsx new file mode 100644 index 0000000..6121159 --- /dev/null +++ b/src/components/BackgroundToggle.stories.tsx @@ -0,0 +1,98 @@ +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, + }, +} \ No newline at end of file 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/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()), + ) +} From 28b36e52ddc19dc5aa9c0aaec757f101a3e9b910 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 07:48:11 -0400 Subject: [PATCH 06/19] feat: enhance useStreamingChat with background job handling and response event processing --- src/hooks/useStreamingChat.test.tsx | 117 +++++++++++++++++++ src/hooks/useStreamingChat.ts | 39 ++++++- src/lib/__snapshots__/streaming.test.ts.snap | 37 ++++++ src/lib/streaming.test.ts | 88 +++++++++++--- src/lib/streaming.ts | 11 +- src/lib/utils/streaming.ts | 9 ++ 6 files changed, 278 insertions(+), 23 deletions(-) create mode 100644 src/lib/__snapshots__/streaming.test.ts.snap diff --git a/src/hooks/useStreamingChat.test.tsx b/src/hooks/useStreamingChat.test.tsx index 7883f2b..f57568b 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: 'bg-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: true, + title: 'Test Background Job', + }) + }) + + await waitFor(() => { + expect(result.current.streamBuffer).toHaveLength(1) + }) + + expect(result.current.streamBuffer[0]).toEqual({ + type: 'assistant', + id: 'bg-job-123', + 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..2e4244d 100644 --- a/src/hooks/useStreamingChat.ts +++ b/src/hooks/useStreamingChat.ts @@ -3,6 +3,7 @@ import { generateMessageId } from '../mcp/client' import type { AnnotatedFile } from '@/lib/utils/code-interpreter' import { stopStreamProcessing } from '@/lib/utils/streaming' import { getTimestamp } from '@/lib/utils/date' +import { useBackgroundJobs } from './useBackgroundJobs' export type AssistantStreamEvent = { type: 'assistant' @@ -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,33 @@ export function useStreamingChat(): UseStreamingChatReturn { return } + // Currently only used for background jobs + if (toolState.type === 'response.created') { + if (options?.background) { + const requestId = toolState.response.id + + const backgroundJob = { + id: requestId, + 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/streaming.test.ts b/src/lib/streaming.test.ts index 2cb193e..6881123 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 { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { stopStreamProcessing, getMessageId } 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,31 @@ describe('stopStreamProcessing', () => { }) }) +describe('getMessageId', () => { + it('returns a unique message ID with msg prefix', async () => { + const actual = await vi.importActual('./utils/streaming') as { getMessageId: () => string } + 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 +172,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 +192,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 +205,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()}` +} From e2bd45dd5224e4dcc67a190e2f837d4f54c44e73 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 07:59:57 -0400 Subject: [PATCH 07/19] fix: add missing key prop to list items in BackgroundJobsSidebar component --- src/components/BackgroundJobsSidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/BackgroundJobsSidebar.tsx b/src/components/BackgroundJobsSidebar.tsx index 62d4fda..bd9b924 100644 --- a/src/components/BackgroundJobsSidebar.tsx +++ b/src/components/BackgroundJobsSidebar.tsx @@ -46,7 +46,7 @@ export function BackgroundJobsSidebar({ ) : (
    {jobs.map((job) => ( -
  • +
  • Date: Wed, 23 Jul 2025 08:00:30 -0400 Subject: [PATCH 08/19] feat: add background handling to chat request processing --- src/routes/api/chat.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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') ? { From ed814d87a34b48677e92dd9b63a84fa4f35a7f3b Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 08:01:01 -0400 Subject: [PATCH 09/19] feat: add GET endpoint for retrieving background job as a streamed response --- src/routes/api/background-jobs.ts | 77 ++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/src/routes/api/background-jobs.ts b/src/routes/api/background-jobs.ts index e11fbfb..2aabad1 100644 --- a/src/routes/api/background-jobs.ts +++ b/src/routes/api/background-jobs.ts @@ -1,5 +1,10 @@ import { createServerFileRoute } from '@tanstack/react-start/server' -import { handleJobStatusRequest, BackgroundJobError } from '@/lib/background-jobs' +import { + handleJobStatusRequest, + BackgroundJobError, +} from '@/lib/background-jobs' +import { streamText } from '@/lib/streaming' +import OpenAI from 'openai' export const ServerRoute = createServerFileRoute( '/api/background-jobs', @@ -17,7 +22,7 @@ export const ServerRoute = createServerFileRoute( console.error('Error in background jobs route:', error) if (error instanceof BackgroundJobError) { - const responseBody = error.message.startsWith('{') + const responseBody = error.message.startsWith('{') ? error.message // Already JSON string for validation errors : JSON.stringify({ error: error.message }) @@ -33,4 +38,72 @@ export const ServerRoute = createServerFileRoute( }) } }, + 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' }, + }, + ) + } + }, }) From 6b89d37b2ba34926c8a883d2fcf466c2e7c2056b Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 08:01:30 -0400 Subject: [PATCH 10/19] feat: integrate background job handling in Chat component with UI updates --- src/components/Chat.tsx | 144 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 133 insertions(+), 11 deletions(-) diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index fb51816..26f49b7 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 { MessageSquarePlus, Clock } 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,66 @@ 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) => { + if (message) { + const job = backgroundJobs.find( + (j) => j.id === message.backgroundJobId, + ) + 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 { + 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], + ) + + 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 +283,46 @@ export function Chat() { /> ), }, + { + key: 'background', + isActive: useBackground, + component: ( + + ), + }, ] return (
    - +
    + + {hasMounted && ( + + )} +
    @@ -313,6 +423,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 +555,12 @@ export function Chat() { focusTimestamp={focusTimestamp} />
    + setBackgroundJobsSidebarOpen(false)} + onLoadResponse={handleLoadJobResponse} + onCancelJob={handleCancelJob} + />
    ) } From 8dfa5ded3fb89175a7d13159f602efa7dc057d51 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 08:10:33 -0400 Subject: [PATCH 11/19] feat: fixed polling in useBackgroundJobStatus hook --- src/hooks/useBackgroundJobStatus.test.tsx | 44 +++++++++++++++++++---- src/hooks/useBackgroundJobStatus.ts | 2 +- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/hooks/useBackgroundJobStatus.test.tsx b/src/hooks/useBackgroundJobStatus.test.tsx index f474876..9553acc 100644 --- a/src/hooks/useBackgroundJobStatus.test.tsx +++ b/src/hooks/useBackgroundJobStatus.test.tsx @@ -106,11 +106,12 @@ describe('useBackgroundJobStatus', () => { }) describe('when job is failed', () => { - it('does not fetch job status', () => { + 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), { @@ -121,6 +122,37 @@ describe('useBackgroundJobStatus', () => { 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', () => { @@ -154,7 +186,7 @@ describe('useBackgroundJobStatus', () => { it('handles HTTP 404 errors without retrying', async () => { const job = { - id: 'job-5', + id: 'job-6', status: 'running' as const, createdAt: getTimestamp(), } @@ -180,7 +212,7 @@ describe('useBackgroundJobStatus', () => { it('calls fetch with correct parameters for error scenarios', async () => { const job = { - id: 'job-6', + id: 'job-7', status: 'running' as const, createdAt: getTimestamp(), } @@ -195,14 +227,14 @@ describe('useBackgroundJobStatus', () => { expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: 'job-6' }), + body: JSON.stringify({ id: 'job-7' }), }) }) }) it('calls fetch for HTTP error responses', async () => { const job = { - id: 'job-7', + id: 'job-8', status: 'running' as const, createdAt: getTimestamp(), } @@ -220,7 +252,7 @@ describe('useBackgroundJobStatus', () => { expect(mockFetch).toHaveBeenCalledWith('/api/background-jobs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: 'job-7' }), + body: JSON.stringify({ id: 'job-8' }), }) }) }) diff --git a/src/hooks/useBackgroundJobStatus.ts b/src/hooks/useBackgroundJobStatus.ts index 05627af..3be4184 100644 --- a/src/hooks/useBackgroundJobStatus.ts +++ b/src/hooks/useBackgroundJobStatus.ts @@ -49,7 +49,7 @@ export const useBackgroundJobStatus = ( } return fetchJobStatus(job.id) }, - enabled: !!job?.id && job.status === 'running', + enabled: !!job?.id && (job.status === 'running' || (job.status === 'failed' && !job.completedAt)), refetchInterval, refetchIntervalInBackground: true, retry: (failureCount, error) => { From 8d9bbc68c5e6628b0d1bc0e38e9572f2d6169056 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 08:21:20 -0400 Subject: [PATCH 12/19] chore: formatting and lint fixes --- .storybook/preview.tsx | 2 +- src/components/BackgroundJobItem.stories.tsx | 2 +- src/components/BackgroundJobItem.tsx | 6 +-- src/components/BackgroundJobsSidebar.tsx | 4 +- src/components/BackgroundToggle.stories.tsx | 6 ++- src/components/Chat.tsx | 15 +++---- src/components/ui/sidebar.tsx | 27 +++++------- src/hooks/useBackgroundJobStatus.test.tsx | 8 +++- src/hooks/useBackgroundJobStatus.ts | 5 ++- src/hooks/useBackgroundJobs.test.tsx | 10 ++--- src/hooks/useBackgroundJobs.ts | 7 +-- src/hooks/useStreamingChat.ts | 6 +-- src/lib/background-jobs.test.ts | 46 ++++++++++++-------- src/lib/streaming.test.ts | 16 ++++--- src/routes/api/background-jobs.ts | 4 +- 15 files changed, 87 insertions(+), 77 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index a3d228a..bb24ea4 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,5 +1,5 @@ -import type { Preview } from '@storybook/react-vite' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { Preview } from '@storybook/react-vite' import '../src/styles.css' const queryClient = new QueryClient({ diff --git a/src/components/BackgroundJobItem.stories.tsx b/src/components/BackgroundJobItem.stories.tsx index 6a820f3..855e762 100644 --- a/src/components/BackgroundJobItem.stories.tsx +++ b/src/components/BackgroundJobItem.stories.tsx @@ -1,5 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/react' import { BackgroundJobItem } from './BackgroundJobItem' +import type { Meta, StoryObj } from '@storybook/react-vite' import type { BackgroundJob } from '@/lib/schemas' const meta: Meta = { diff --git a/src/components/BackgroundJobItem.tsx b/src/components/BackgroundJobItem.tsx index 154b9dc..6db4733 100644 --- a/src/components/BackgroundJobItem.tsx +++ b/src/components/BackgroundJobItem.tsx @@ -1,8 +1,8 @@ +import { Clock, Play, Trash2, X } from 'lucide-react' +import { useEffect } from 'react' import { Button } from './ui/button' -import { Clock, Play, X, Trash2 } from 'lucide-react' -import { useBackgroundJobStatus } from '@/hooks/useBackgroundJobStatus' import type { BackgroundJob } from '@/lib/schemas' -import { useEffect } from 'react' +import { useBackgroundJobStatus } from '@/hooks/useBackgroundJobStatus' export function BackgroundJobItem({ job, diff --git a/src/components/BackgroundJobsSidebar.tsx b/src/components/BackgroundJobsSidebar.tsx index bd9b924..9a249be 100644 --- a/src/components/BackgroundJobsSidebar.tsx +++ b/src/components/BackgroundJobsSidebar.tsx @@ -1,8 +1,8 @@ +import { Clock } from 'lucide-react' import { Sidebar } from './ui/sidebar' +import { BackgroundJobItem } from './BackgroundJobItem' import { useHasMounted } from '@/hooks/useHasMounted' import { useBackgroundJobs } from '@/hooks/useBackgroundJobs' -import { Clock } from 'lucide-react' -import { BackgroundJobItem } from './BackgroundJobItem' interface BackgroundJobsSidebarProps { isOpen: boolean diff --git a/src/components/BackgroundToggle.stories.tsx b/src/components/BackgroundToggle.stories.tsx index 6121159..5c26ab0 100644 --- a/src/components/BackgroundToggle.stories.tsx +++ b/src/components/BackgroundToggle.stories.tsx @@ -27,7 +27,9 @@ export default meta type Story = StoryObj const Template = (args: any) => { - const [useBackground, setUseBackground] = useState(args.useBackground ?? false) + const [useBackground, setUseBackground] = useState( + args.useBackground ?? false, + ) return ( { setMessages((prevMessages) => prevMessages.map((message) => { - if (message) { - const job = backgroundJobs.find( - (j) => j.id === message.backgroundJobId, - ) - if (job && job.response && job.response !== message.content) { - // Update message content with latest job response - return { ...message, content: job.response } - } + 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 }), ) diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index b2dfa45..f56d463 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -1,7 +1,7 @@ import * as React from 'react' -import { cn } from '@/lib/utils' -import { Button } from './button' import { X } from 'lucide-react' +import { Button } from './button' +import { cn } from '@/lib/utils' interface SidebarProps { children: React.ReactNode @@ -11,29 +11,26 @@ interface SidebarProps { className?: string } -export function Sidebar({ - children, - isOpen, - onClose, - title, - className +export function Sidebar({ + children, + isOpen, + onClose, + title, + className, }: SidebarProps) { return ( <> {/* Overlay */} {isOpen && ( -
    +
    )} - + {/* Sidebar */}
    @@ -53,4 +50,4 @@ export function Sidebar({
    ) -} \ No newline at end of file +} diff --git a/src/hooks/useBackgroundJobStatus.test.tsx b/src/hooks/useBackgroundJobStatus.test.tsx index 9553acc..a1ef52f 100644 --- a/src/hooks/useBackgroundJobStatus.test.tsx +++ b/src/hooks/useBackgroundJobStatus.test.tsx @@ -1,5 +1,5 @@ import { renderHook, waitFor } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +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' @@ -132,7 +132,11 @@ describe('useBackgroundJobStatus', () => { mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ status: 'failed', error: 'Job failed', completedAt: getTimestamp() }), + json: async () => ({ + status: 'failed', + error: 'Job failed', + completedAt: getTimestamp(), + }), }) const { result } = renderHook(() => useBackgroundJobStatus(job), { diff --git a/src/hooks/useBackgroundJobStatus.ts b/src/hooks/useBackgroundJobStatus.ts index 3be4184..c15ce19 100644 --- a/src/hooks/useBackgroundJobStatus.ts +++ b/src/hooks/useBackgroundJobStatus.ts @@ -49,7 +49,10 @@ export const useBackgroundJobStatus = ( } return fetchJobStatus(job.id) }, - enabled: !!job?.id && (job.status === 'running' || (job.status === 'failed' && !job.completedAt)), + enabled: + !!job?.id && + (job.status === 'running' || + (job.status === 'failed' && !job.completedAt)), refetchInterval, refetchIntervalInBackground: true, retry: (failureCount, error) => { diff --git a/src/hooks/useBackgroundJobs.test.tsx b/src/hooks/useBackgroundJobs.test.tsx index e3d62c0..5dbabf7 100644 --- a/src/hooks/useBackgroundJobs.test.tsx +++ b/src/hooks/useBackgroundJobs.test.tsx @@ -1,5 +1,5 @@ -import { renderHook, act } from '@testing-library/react' -import { describe, it, expect, beforeEach } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' import { useBackgroundJobs } from './useBackgroundJobs' import type { BackgroundJob } from '../lib/schemas' @@ -302,7 +302,7 @@ describe('useBackgroundJobs', () => { title: 'Session 2 Job', }) - let firstSessionResult = renderHook(() => useBackgroundJobs()) + const firstSessionResult = renderHook(() => useBackgroundJobs()) expect(firstSessionResult.result.current.jobs).toHaveLength(0) expect(localStorage.getItem(STORAGE_KEY)).toBeNull() @@ -319,7 +319,7 @@ describe('useBackgroundJobs', () => { expect(firstSessionResult.result.current.jobs).toHaveLength(2) firstSessionResult.unmount() - let secondSessionResult = renderHook(() => useBackgroundJobs()) + const secondSessionResult = renderHook(() => useBackgroundJobs()) expect(secondSessionResult.result.current.jobs).toHaveLength(2) expect( secondSessionResult.result.current.getJobById('browser-session-alpha'), @@ -350,7 +350,7 @@ describe('useBackgroundJobs', () => { secondSessionResult.unmount() - let thirdSessionResult = renderHook(() => useBackgroundJobs()) + const thirdSessionResult = renderHook(() => useBackgroundJobs()) expect(thirdSessionResult.result.current.jobs).toHaveLength(1) const persistedJob = thirdSessionResult.result.current.getJobById( diff --git a/src/hooks/useBackgroundJobs.ts b/src/hooks/useBackgroundJobs.ts index 98a7a44..5b8f1e6 100644 --- a/src/hooks/useBackgroundJobs.ts +++ b/src/hooks/useBackgroundJobs.ts @@ -5,7 +5,7 @@ import type { BackgroundJob } from '../lib/schemas' const STORAGE_KEY = 'background-jobs' interface UseBackgroundJobsReturn { - jobs: BackgroundJob[] + jobs: Array jobsMap: Record addJob: (job: BackgroundJob) => void removeJob: (id: string) => void @@ -35,7 +35,7 @@ export const useBackgroundJobs = (): UseBackgroundJobsReturn => { const removeJob = useCallback( (id: string) => { setJobsMap((prev) => { - const { [id]: removed, ...rest } = prev + const { [id]: _removed, ...rest } = prev return rest }) }, @@ -45,9 +45,6 @@ export const useBackgroundJobs = (): UseBackgroundJobsReturn => { const updateJob = useCallback( (id: string, updates: Partial) => { setJobsMap((prev) => { - if (!prev[id]) { - return prev - } return { ...prev, [id]: { ...prev[id], ...updates }, diff --git a/src/hooks/useStreamingChat.ts b/src/hooks/useStreamingChat.ts index 2e4244d..5de9a06 100644 --- a/src/hooks/useStreamingChat.ts +++ b/src/hooks/useStreamingChat.ts @@ -1,9 +1,9 @@ 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' -import { useBackgroundJobs } from './useBackgroundJobs' export type AssistantStreamEvent = { type: 'assistant' @@ -377,10 +377,8 @@ export function useStreamingChat(): UseStreamingChatReturn { // Currently only used for background jobs if (toolState.type === 'response.created') { if (options?.background) { - const requestId = toolState.response.id - const backgroundJob = { - id: requestId, + id: toolState.response.id, status: 'running' as const, createdAt: getTimestamp(), title: options.title, diff --git a/src/lib/background-jobs.test.ts b/src/lib/background-jobs.test.ts index fba7713..178365b 100644 --- a/src/lib/background-jobs.test.ts +++ b/src/lib/background-jobs.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import OpenAI from 'openai' -import { handleJobStatusRequest, BackgroundJobError } from './background-jobs' +import { BackgroundJobError, handleJobStatusRequest } from './background-jobs' // Mock OpenAI vi.mock('openai') @@ -18,7 +18,7 @@ describe('handleJobStatusRequest', () => { retrieve: vi.fn(), }, } - + vi.mocked(OpenAI).mockImplementation(() => mockOpenAI as any) }) @@ -28,8 +28,10 @@ describe('handleJobStatusRequest', () => { describe('input validation', () => { it('throws BackgroundJobError for missing request body', async () => { - await expect(handleJobStatusRequest({})).rejects.toThrow(BackgroundJobError) - + await expect(handleJobStatusRequest({})).rejects.toThrow( + BackgroundJobError, + ) + try { await handleJobStatusRequest({}) } catch (error) { @@ -39,8 +41,10 @@ describe('handleJobStatusRequest', () => { }) it('throws BackgroundJobError for invalid id field', async () => { - await expect(handleJobStatusRequest({ id: '' })).rejects.toThrow(BackgroundJobError) - + await expect(handleJobStatusRequest({ id: '' })).rejects.toThrow( + BackgroundJobError, + ) + try { await handleJobStatusRequest({ id: '' }) } catch (error) { @@ -112,7 +116,7 @@ describe('handleJobStatusRequest', () => { 404, { message: 'Not found' }, 'Not found', - {} + {}, ) apiError.status = 404 mockOpenAI.responses.retrieve.mockRejectedValue(apiError) @@ -128,7 +132,7 @@ describe('handleJobStatusRequest', () => { 401, { message: 'Unauthorized' }, 'Unauthorized', - {} + {}, ) apiError.status = 401 mockOpenAI.responses.retrieve.mockRejectedValue(apiError) @@ -144,7 +148,7 @@ describe('handleJobStatusRequest', () => { 403, { message: 'Forbidden' }, 'Forbidden', - {} + {}, ) apiError.status = 403 mockOpenAI.responses.retrieve.mockRejectedValue(apiError) @@ -160,7 +164,7 @@ describe('handleJobStatusRequest', () => { 429, { message: 'Rate limit exceeded' }, 'Rate limit exceeded', - {} + {}, ) apiError.status = 429 mockOpenAI.responses.retrieve.mockRejectedValue(apiError) @@ -176,7 +180,7 @@ describe('handleJobStatusRequest', () => { 502, { message: 'Bad gateway' }, 'Bad gateway', - {} + {}, ) mockOpenAI.responses.retrieve.mockRejectedValue(apiError) @@ -191,7 +195,7 @@ describe('handleJobStatusRequest', () => { undefined, { message: 'Unknown error' }, 'Unknown error', - {} + {}, ) apiError.status = undefined mockOpenAI.responses.retrieve.mockRejectedValue(apiError) @@ -208,8 +212,10 @@ describe('handleJobStatusRequest', () => { const networkError = new Error('Network connection failed') mockOpenAI.responses.retrieve.mockRejectedValue(networkError) - await expect(handleJobStatusRequest({ id: 'network-error-job' })).rejects.toThrow(BackgroundJobError) - + await expect( + handleJobStatusRequest({ id: 'network-error-job' }), + ).rejects.toThrow(BackgroundJobError) + try { await handleJobStatusRequest({ id: 'network-error-job' }) } catch (error) { @@ -221,8 +227,10 @@ describe('handleJobStatusRequest', () => { 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) - + await expect(handleJobStatusRequest('{ invalid json')).rejects.toThrow( + BackgroundJobError, + ) + try { await handleJobStatusRequest('{ invalid json') } catch (error) { @@ -243,8 +251,8 @@ describe('handleJobStatusRequest', () => { expect(result).toHaveProperty('completedAt') expect(new Date(result.completedAt!).getTime()).toBeGreaterThanOrEqual( - new Date(beforeTime).getTime() + new Date(beforeTime).getTime(), ) }) }) -}) \ No newline at end of file +}) diff --git a/src/lib/streaming.test.ts b/src/lib/streaming.test.ts index 6881123..5ec1936 100644 --- a/src/lib/streaming.test.ts +++ b/src/lib/streaming.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import { stopStreamProcessing, getMessageId } 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 () => { @@ -93,12 +93,16 @@ describe('stopStreamProcessing', () => { describe('getMessageId', () => { it('returns a unique message ID with msg prefix', async () => { - const actual = await vi.importActual('./utils/streaming') as { getMessageId: () => string } + 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).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) }) }) diff --git a/src/routes/api/background-jobs.ts b/src/routes/api/background-jobs.ts index 2aabad1..059f73d 100644 --- a/src/routes/api/background-jobs.ts +++ b/src/routes/api/background-jobs.ts @@ -1,10 +1,10 @@ import { createServerFileRoute } from '@tanstack/react-start/server' +import OpenAI from 'openai' import { - handleJobStatusRequest, BackgroundJobError, + handleJobStatusRequest, } from '@/lib/background-jobs' import { streamText } from '@/lib/streaming' -import OpenAI from 'openai' export const ServerRoute = createServerFileRoute( '/api/background-jobs', From 143bfafa2ea6d943f5d835d8ad58c96d5cfeceda Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 08:37:23 -0400 Subject: [PATCH 13/19] fix: fixed broken test after over eslinting --- src/hooks/useBackgroundJobs.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useBackgroundJobs.ts b/src/hooks/useBackgroundJobs.ts index 5b8f1e6..d27a36f 100644 --- a/src/hooks/useBackgroundJobs.ts +++ b/src/hooks/useBackgroundJobs.ts @@ -45,6 +45,9 @@ export const useBackgroundJobs = (): UseBackgroundJobsReturn => { const updateJob = useCallback( (id: string, updates: Partial) => { setJobsMap((prev) => { + if (!(id in prev)) { + return prev + } return { ...prev, [id]: { ...prev[id], ...updates }, From 793ed39acdb122815b0b6eb3b667047decf4a795 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 08:43:48 -0400 Subject: [PATCH 14/19] refactor: remove debug logging from job status update effect --- src/components/BackgroundJobItem.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/components/BackgroundJobItem.tsx b/src/components/BackgroundJobItem.tsx index 6db4733..7e354b1 100644 --- a/src/components/BackgroundJobItem.tsx +++ b/src/components/BackgroundJobItem.tsx @@ -29,9 +29,6 @@ export function BackgroundJobItem({ // Update job when status changes useEffect(() => { if (statusData) { - console.log('Polling data received:', statusData) - console.log('Current job:', job) - const needsUpdate = statusData.status !== job.status || statusData.response !== job.response || @@ -39,13 +36,6 @@ export function BackgroundJobItem({ statusData.completedAt !== job.completedAt if (needsUpdate) { - console.log('Updating job with:', { - status: statusData.status, - response: statusData.response, - error: statusData.error, - completedAt: statusData.completedAt, - }) - updateJob(job.id, { status: statusData.status, response: statusData.response || job.response, From 2409269629c074122b71f21fe5fca5bee5f10738 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 09:43:14 -0400 Subject: [PATCH 15/19] feat: add zustand as a dependency --- package-lock.json | 32 +++++++++++++++++++++++++++++++- package.json | 3 ++- 2 files changed, 33 insertions(+), 2 deletions(-) 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", From 5d7686ac838342f1309aa6a1ca2f7cb9403aaf15 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 11:13:55 -0400 Subject: [PATCH 16/19] feat: implement zustand store for background jobs management --- src/hooks/useBackgroundJobs.test.tsx | 45 +++- src/hooks/useBackgroundJobs.ts | 63 +---- src/hooks/useBackgroundJobsStore.test.ts | 301 +++++++++++++++++++++++ src/hooks/useBackgroundJobsStore.ts | 67 +++++ 4 files changed, 415 insertions(+), 61 deletions(-) create mode 100644 src/hooks/useBackgroundJobsStore.test.ts create mode 100644 src/hooks/useBackgroundJobsStore.ts diff --git a/src/hooks/useBackgroundJobs.test.tsx b/src/hooks/useBackgroundJobs.test.tsx index 5dbabf7..b17050e 100644 --- a/src/hooks/useBackgroundJobs.test.tsx +++ b/src/hooks/useBackgroundJobs.test.tsx @@ -1,13 +1,18 @@ 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', () => { - const STORAGE_KEY = 'background-jobs' - beforeEach(() => { - localStorage.clear() + const { result } = renderHook(() => useBackgroundJobs()) + act(() => { + result.current.clearAllJobs() + }) }) const createMockJob = ( @@ -21,6 +26,19 @@ describe('useBackgroundJobs', () => { }) describe('initialization and persistence', () => { + it('should reset jobs using resetJobs()', () => { + const { result } = renderHook(() => useBackgroundJobs()) + const job = createMockJob({ id: 'reset-job' }) + 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()) @@ -33,7 +51,8 @@ describe('useBackgroundJobs', () => { const job2 = createMockJob({ id: 'job2', status: 'completed' }) const storedData = { job1, job2 } - localStorage.setItem(STORAGE_KEY, JSON.stringify(storedData)) + // Initialize store with existing data using proper API + useBackgroundJobsStore.getState().initializeJobs(storedData) const { result } = renderHook(() => useBackgroundJobs()) @@ -229,8 +248,10 @@ describe('useBackgroundJobs', () => { expect(finalJob?.response).toBe('Job completed successfully') expect(finalJob?.completedAt).toBe('2025-01-01T01:00:00Z') - const storedData = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') - expect(storedData[job.id]).toEqual(finalJob) + const storedData = JSON.parse( + localStorage.getItem(BACKGROUND_JOBS_STORAGE_KEY) || '{}', + ) + expect(storedData.state.jobs[job.id]).toEqual(finalJob) }) it('should handle concurrent job operations', () => { @@ -252,7 +273,11 @@ describe('useBackgroundJobs', () => { }) expect(result.current.jobs).toHaveLength(0) - expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + // 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) @@ -305,7 +330,11 @@ describe('useBackgroundJobs', () => { const firstSessionResult = renderHook(() => useBackgroundJobs()) expect(firstSessionResult.result.current.jobs).toHaveLength(0) - expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + // 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) diff --git a/src/hooks/useBackgroundJobs.ts b/src/hooks/useBackgroundJobs.ts index d27a36f..37f9bca 100644 --- a/src/hooks/useBackgroundJobs.ts +++ b/src/hooks/useBackgroundJobs.ts @@ -1,9 +1,7 @@ -import { useCallback, useMemo } from 'react' -import { useLocalStorage } from './useLocalStorage' +import { useMemo } from 'react' +import { useBackgroundJobsStore } from './useBackgroundJobsStore' import type { BackgroundJob } from '../lib/schemas' -const STORAGE_KEY = 'background-jobs' - interface UseBackgroundJobsReturn { jobs: Array jobsMap: Record @@ -15,58 +13,17 @@ interface UseBackgroundJobsReturn { } export const useBackgroundJobs = (): UseBackgroundJobsReturn => { - const [jobsMap, setJobsMap] = useLocalStorage>( - STORAGE_KEY, - {}, - ) + 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 addJob = useCallback( - (job: BackgroundJob) => { - setJobsMap((prev) => ({ - ...prev, - [job.id]: job, - })) - }, - [setJobsMap], - ) - - const removeJob = useCallback( - (id: string) => { - setJobsMap((prev) => { - const { [id]: _removed, ...rest } = prev - return rest - }) - }, - [setJobsMap], - ) - - const updateJob = useCallback( - (id: string, updates: Partial) => { - setJobsMap((prev) => { - if (!(id in prev)) { - return prev - } - return { - ...prev, - [id]: { ...prev[id], ...updates }, - } - }) - }, - [setJobsMap], - ) - - const getJobById = useCallback( - (id: string) => { - return jobsMap[id] - }, - [jobsMap], - ) - - const clearAllJobs = useCallback(() => { - setJobsMap({}) - }, [setJobsMap]) + const getJobById = (id: string) => { + return jobsMap[id] + } return { jobs, diff --git a/src/hooks/useBackgroundJobsStore.test.ts b/src/hooks/useBackgroundJobsStore.test.ts new file mode 100644 index 0000000..0102fc8 --- /dev/null +++ b/src/hooks/useBackgroundJobsStore.test.ts @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { BackgroundJob } from '@/lib/schemas' +import { useBackgroundJobsStore } from './useBackgroundJobsStore' + +describe('useBackgroundJobsStore', () => { + beforeEach(() => { + // Reset store state using proper API + useBackgroundJobsStore.getState().clearAllJobs() + }) + + const mockJob: BackgroundJob = { + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da86', + 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_6880e725623081a1af3dc14ba0d562620d62da86: 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_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + 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_6880e725623081a1af3dc14ba0d562620d62da86: mockJob, + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2: 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_6880e725623081a1af3dc14ba0d562620d62da86']).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_6880e725623081a1af3dc14ba0d562620d62da86') + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual({}) + }) + + it('should not affect other jobs when removing one', () => { + const job2: BackgroundJob = { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + status: 'running', + createdAt: '2023-01-01T01:00:00.000Z', + } + const { addJob, removeJob } = useBackgroundJobsStore.getState() + + addJob(job2) + removeJob('resp_6880e725623081a1af3dc14ba0d562620d62da86') + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual({ + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2: 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_6880e725623081a1af3dc14ba0d562620d62da86', updates) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8']).toEqual({ + ...mockJob, + ...updates, + }) + }) + + it('should partially update job properties', () => { + const { updateJob } = useBackgroundJobsStore.getState() + + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da86', { + status: 'failed', + error: 'Something went wrong', + }) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da86']).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_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + status: 'running', + createdAt: '2023-01-01T01:00:00.000Z', + } + const { addJob, updateJob } = useBackgroundJobsStore.getState() + + addJob(job2) + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da86', { + status: 'completed', + }) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs['resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2']).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_6880e725623081a1af3dc14ba0d562620d62da86', { + status: 'invalid' as any, + }), + ).toThrow() + }) + }) + + describe('clearAllJobs', () => { + beforeEach(() => { + const { addJob } = useBackgroundJobsStore.getState() + addJob(mockJob) + addJob({ + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + 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_6880e725623081a1af3dc14ba0d562620d62da86: mockJob, + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2: { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + 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_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2: { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + status: 'completed' as const, + createdAt: '2023-01-01T01:00:00.000Z', + }, + } + + initializeJobs(newJobs) + + const { jobs } = useBackgroundJobsStore.getState() + expect(jobs).toEqual(newJobs) + expect( + jobs['resp_6880e725623081a1af3dc14ba0d562620d62da86'], + ).toBeUndefined() + }) + + it('should validate all jobs when initializing', () => { + const { initializeJobs } = useBackgroundJobsStore.getState() + const invalidJobs = { + resp_6880e725623081a1af3dc14ba0d562620d62da86: { + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da86', + 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..327b72a --- /dev/null +++ b/src/hooks/useBackgroundJobsStore.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { backgroundJobSchema, type BackgroundJob } 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, + }, + ), +) From 16956c47aaae27e23c6efbd8f22d1bff3683a1eb Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 11:37:52 -0400 Subject: [PATCH 17/19] test: update job IDs in tests to follow new format in Zod schema --- src/hooks/useBackgroundJobs.test.tsx | 140 ++++++++++++++++------- src/hooks/useBackgroundJobsStore.test.ts | 56 +++++---- src/hooks/useBackgroundJobsStore.ts | 3 +- src/hooks/useStreamingChat.test.tsx | 4 +- src/lib/background-jobs.test.ts | 102 +++++++++++++---- src/lib/schemas.ts | 7 +- 6 files changed, 214 insertions(+), 98 deletions(-) diff --git a/src/hooks/useBackgroundJobs.test.tsx b/src/hooks/useBackgroundJobs.test.tsx index b17050e..32d4e6b 100644 --- a/src/hooks/useBackgroundJobs.test.tsx +++ b/src/hooks/useBackgroundJobs.test.tsx @@ -18,7 +18,7 @@ describe('useBackgroundJobs', () => { const createMockJob = ( overrides: Partial = {}, ): BackgroundJob => ({ - id: 'test-job-1', + id: 'resp_1234567890abcdef1234567890abcdef12345678', status: 'running', createdAt: '2025-01-01T00:00:00Z', title: 'Test Job', @@ -28,7 +28,9 @@ describe('useBackgroundJobs', () => { describe('initialization and persistence', () => { it('should reset jobs using resetJobs()', () => { const { result } = renderHook(() => useBackgroundJobs()) - const job = createMockJob({ id: 'reset-job' }) + const job = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef12', + }) act(() => { result.current.addJob(job) }) @@ -47,8 +49,13 @@ describe('useBackgroundJobs', () => { }) it('should load existing jobs from localStorage', () => { - const job1 = createMockJob({ id: 'job1' }) - const job2 = createMockJob({ id: 'job2', status: 'completed' }) + 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 @@ -96,8 +103,14 @@ describe('useBackgroundJobs', () => { it('should add multiple jobs', () => { const { result } = renderHook(() => useBackgroundJobs()) - const job1 = createMockJob({ id: 'job1', title: 'Job 1' }) - const job2 = createMockJob({ id: 'job2', title: 'Job 2' }) + const job1 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef11', + title: 'Job 1', + }) + const job2 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef22', + title: 'Job 2', + }) expect(result.current.jobs).toHaveLength(0) @@ -134,8 +147,12 @@ describe('useBackgroundJobs', () => { it('should remove a job by id', () => { const { result } = renderHook(() => useBackgroundJobs()) - const job1 = createMockJob({ id: 'job1' }) - const job2 = createMockJob({ id: 'job2' }) + const job1 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef11', + }) + const job2 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef22', + }) act(() => { result.current.addJob(job1) @@ -143,13 +160,23 @@ describe('useBackgroundJobs', () => { }) act(() => { - result.current.removeJob('job1') + result.current.removeJob( + 'resp_abcdef1234567890abcdef1234567890abcdef11', + ) }) expect(result.current.jobs).toHaveLength(1) expect(result.current.jobs).toContainEqual(job2) - expect(result.current.getJobById('job1')).toBeUndefined() - expect(result.current.getJobById('job2')).toEqual(job2) + expect( + result.current.getJobById( + 'resp_abcdef1234567890abcdef1234567890abcdef11', + ), + ).toBeUndefined() + expect( + result.current.getJobById( + 'resp_abcdef1234567890abcdef1234567890abcdef22', + ), + ).toEqual(job2) }) it('should handle removing non-existent job gracefully', () => { @@ -191,7 +218,9 @@ describe('useBackgroundJobs', () => { it('should handle updating non-existent job gracefully', () => { const { result } = renderHook(() => useBackgroundJobs()) - const existingJob = createMockJob({ id: 'existing' }) + const existingJob = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef99', + }) act(() => { result.current.addJob(existingJob) @@ -209,8 +238,12 @@ describe('useBackgroundJobs', () => { it('should clear all jobs', () => { const { result } = renderHook(() => useBackgroundJobs()) - const job1 = createMockJob({ id: 'job1' }) - const job2 = createMockJob({ id: 'job2' }) + const job1 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef11', + }) + const job2 = createMockJob({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef22', + }) act(() => { result.current.addJob(job1) @@ -257,17 +290,17 @@ describe('useBackgroundJobs', () => { it('should handle concurrent job operations', () => { const { result } = renderHook(() => useBackgroundJobs()) const runningJob = createMockJob({ - id: 'concurrent-running-xyz', + id: 'resp_1111111111111111111111111111111111111111', status: 'running', title: 'Running Job', }) const completedJob = createMockJob({ - id: 'concurrent-completed-abc', + id: 'resp_2222222222222222222222222222222222222222', status: 'completed', title: 'Completed Job', }) const failedJob = createMockJob({ - id: 'concurrent-failed-def', + id: 'resp_3333333333333333333333333333333333333333', status: 'failed', title: 'Failed Job', }) @@ -296,34 +329,45 @@ describe('useBackgroundJobs', () => { expect(result.current.jobs).toHaveLength(3) act(() => { - result.current.removeJob('concurrent-completed-abc') + result.current.removeJob( + 'resp_2222222222222222222222222222222222222222', + ) }) act(() => { - result.current.updateJob('concurrent-running-xyz', { - status: 'completed', - }) + result.current.updateJob( + 'resp_1111111111111111111111111111111111111111', + { + status: 'completed', + }, + ) }) expect(result.current.jobs).toHaveLength(2) - expect(result.current.getJobById('concurrent-running-xyz')?.status).toBe( - 'completed', - ) expect( - result.current.getJobById('concurrent-completed-abc'), + result.current.getJobById( + 'resp_1111111111111111111111111111111111111111', + )?.status, + ).toBe('completed') + expect( + result.current.getJobById( + 'resp_2222222222222222222222222222222222222222', + ), ).toBeUndefined() - expect(result.current.getJobById('concurrent-failed-def')).toEqual( - failedJob, - ) + expect( + result.current.getJobById( + 'resp_3333333333333333333333333333333333333333', + ), + ).toEqual(failedJob) }) it('should maintain data integrity across browser sessions', () => { const job1 = createMockJob({ - id: 'browser-session-alpha', + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', title: 'Session 1 Job', }) const job2 = createMockJob({ - id: 'browser-session-beta', + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', title: 'Session 2 Job', }) @@ -351,30 +395,42 @@ describe('useBackgroundJobs', () => { const secondSessionResult = renderHook(() => useBackgroundJobs()) expect(secondSessionResult.result.current.jobs).toHaveLength(2) expect( - secondSessionResult.result.current.getJobById('browser-session-alpha'), + secondSessionResult.result.current.getJobById( + 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + ), ).toEqual(job1) expect( - secondSessionResult.result.current.getJobById('browser-session-beta'), + secondSessionResult.result.current.getJobById( + 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + ), ).toEqual(job2) act(() => { - secondSessionResult.result.current.updateJob('browser-session-alpha', { - status: 'completed', - }) + secondSessionResult.result.current.updateJob( + 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + { + status: 'completed', + }, + ) }) expect( - secondSessionResult.result.current.getJobById('browser-session-alpha') - ?.status, + secondSessionResult.result.current.getJobById( + 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + )?.status, ).toBe('completed') act(() => { - secondSessionResult.result.current.removeJob('browser-session-beta') + secondSessionResult.result.current.removeJob( + 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + ) }) expect(secondSessionResult.result.current.jobs).toHaveLength(1) expect( - secondSessionResult.result.current.getJobById('browser-session-beta'), + secondSessionResult.result.current.getJobById( + 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + ), ).toBeUndefined() secondSessionResult.unmount() @@ -383,13 +439,15 @@ describe('useBackgroundJobs', () => { expect(thirdSessionResult.result.current.jobs).toHaveLength(1) const persistedJob = thirdSessionResult.result.current.getJobById( - 'browser-session-alpha', + '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('browser-session-beta'), + thirdSessionResult.result.current.getJobById( + 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2b3e1f4a6c8d', + ), ).toBeUndefined() thirdSessionResult.unmount() }) diff --git a/src/hooks/useBackgroundJobsStore.test.ts b/src/hooks/useBackgroundJobsStore.test.ts index 0102fc8..b84e09f 100644 --- a/src/hooks/useBackgroundJobsStore.test.ts +++ b/src/hooks/useBackgroundJobsStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import type { BackgroundJob } from '@/lib/schemas' import { useBackgroundJobsStore } from './useBackgroundJobsStore' +import type { BackgroundJob } from '@/lib/schemas' describe('useBackgroundJobsStore', () => { beforeEach(() => { @@ -9,7 +9,7 @@ describe('useBackgroundJobsStore', () => { }) const mockJob: BackgroundJob = { - id: 'resp_6880e725623081a1af3dc14ba0d562620d62da86', + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8', status: 'running', createdAt: '2023-01-01T00:00:00.000Z', title: 'Test Job', @@ -23,7 +23,7 @@ describe('useBackgroundJobsStore', () => { const updatedState = useBackgroundJobsStore.getState() expect(updatedState.jobs).toEqual({ - resp_6880e725623081a1af3dc14ba0d562620d62da86: mockJob, + resp_6880e725623081a1af3dc14ba0d562620d62da8: mockJob, }) }) @@ -41,7 +41,7 @@ describe('useBackgroundJobsStore', () => { it('should add multiple jobs', () => { const { addJob } = useBackgroundJobsStore.getState() const job2: BackgroundJob = { - id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', status: 'completed', createdAt: '2023-01-01T01:00:00.000Z', completedAt: '2023-01-01T01:05:00.000Z', @@ -52,8 +52,8 @@ describe('useBackgroundJobsStore', () => { const { jobs } = useBackgroundJobsStore.getState() expect(jobs).toEqual({ - resp_6880e725623081a1af3dc14ba0d562620d62da86: mockJob, - resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2: job2, + resp_6880e725623081a1af3dc14ba0d562620d62da8: mockJob, + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7: job2, }) }) @@ -69,7 +69,7 @@ describe('useBackgroundJobsStore', () => { addJob(updatedJob) const { jobs } = useBackgroundJobsStore.getState() - expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da86']).toEqual( + expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8']).toEqual( updatedJob, ) expect(Object.keys(jobs)).toHaveLength(1) @@ -84,7 +84,7 @@ describe('useBackgroundJobsStore', () => { it('should remove a job from the store', () => { const { removeJob } = useBackgroundJobsStore.getState() - removeJob('resp_6880e725623081a1af3dc14ba0d562620d62da86') + removeJob('resp_6880e725623081a1af3dc14ba0d562620d62da8') const { jobs } = useBackgroundJobsStore.getState() expect(jobs).toEqual({}) @@ -92,18 +92,18 @@ describe('useBackgroundJobsStore', () => { it('should not affect other jobs when removing one', () => { const job2: BackgroundJob = { - id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', status: 'running', createdAt: '2023-01-01T01:00:00.000Z', } const { addJob, removeJob } = useBackgroundJobsStore.getState() addJob(job2) - removeJob('resp_6880e725623081a1af3dc14ba0d562620d62da86') + removeJob('resp_6880e725623081a1af3dc14ba0d562620d62da8') const { jobs } = useBackgroundJobsStore.getState() expect(jobs).toEqual({ - resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2: job2, + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7: job2, }) }) @@ -138,7 +138,7 @@ describe('useBackgroundJobsStore', () => { response: 'Job completed successfully', } - updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da86', updates) + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da8', updates) const { jobs } = useBackgroundJobsStore.getState() expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8']).toEqual({ @@ -150,13 +150,13 @@ describe('useBackgroundJobsStore', () => { it('should partially update job properties', () => { const { updateJob } = useBackgroundJobsStore.getState() - updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da86', { + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da8', { status: 'failed', error: 'Something went wrong', }) const { jobs } = useBackgroundJobsStore.getState() - expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da86']).toEqual({ + expect(jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8']).toEqual({ ...mockJob, status: 'failed', error: 'Something went wrong', @@ -175,21 +175,19 @@ describe('useBackgroundJobsStore', () => { it('should not modify other jobs when updating one', () => { const job2: BackgroundJob = { - id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', status: 'running', createdAt: '2023-01-01T01:00:00.000Z', } const { addJob, updateJob } = useBackgroundJobsStore.getState() addJob(job2) - updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da86', { + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da8', { status: 'completed', }) const { jobs } = useBackgroundJobsStore.getState() - expect(jobs['resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2']).toEqual( - job2, - ) + expect(jobs['resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7']).toEqual(job2) }) it('should throw error for empty job ID', () => { @@ -208,7 +206,7 @@ describe('useBackgroundJobsStore', () => { // Should throw for invalid status expect(() => - updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da86', { + updateJob('resp_6880e725623081a1af3dc14ba0d562620d62da8', { status: 'invalid' as any, }), ).toThrow() @@ -220,7 +218,7 @@ describe('useBackgroundJobsStore', () => { const { addJob } = useBackgroundJobsStore.getState() addJob(mockJob) addJob({ - id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', status: 'completed', createdAt: '2023-01-01T01:00:00.000Z', }) @@ -240,9 +238,9 @@ describe('useBackgroundJobsStore', () => { it('should initialize store with provided jobs', () => { const { initializeJobs } = useBackgroundJobsStore.getState() const initialJobs = { - resp_6880e725623081a1af3dc14ba0d562620d62da86: mockJob, - resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2: { - id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + resp_6880e725623081a1af3dc14ba0d562620d62da8: mockJob, + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7: { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', status: 'completed' as const, createdAt: '2023-01-01T01:00:00.000Z', }, @@ -261,8 +259,8 @@ describe('useBackgroundJobsStore', () => { // Initialize with different jobs const newJobs = { - resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2: { - id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7c2', + resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7: { + id: 'resp_7e2b1c8e9f4a3d2b6c1e8f7a9d4c3b2e1f6a8d7', status: 'completed' as const, createdAt: '2023-01-01T01:00:00.000Z', }, @@ -273,15 +271,15 @@ describe('useBackgroundJobsStore', () => { const { jobs } = useBackgroundJobsStore.getState() expect(jobs).toEqual(newJobs) expect( - jobs['resp_6880e725623081a1af3dc14ba0d562620d62da86'], + jobs['resp_6880e725623081a1af3dc14ba0d562620d62da8'], ).toBeUndefined() }) it('should validate all jobs when initializing', () => { const { initializeJobs } = useBackgroundJobsStore.getState() const invalidJobs = { - resp_6880e725623081a1af3dc14ba0d562620d62da86: { - id: 'resp_6880e725623081a1af3dc14ba0d562620d62da86', + resp_6880e725623081a1af3dc14ba0d562620d62da8: { + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8', status: 'invalid' as any, createdAt: '2023-01-01T00:00:00.000Z', }, diff --git a/src/hooks/useBackgroundJobsStore.ts b/src/hooks/useBackgroundJobsStore.ts index 327b72a..0b9a883 100644 --- a/src/hooks/useBackgroundJobsStore.ts +++ b/src/hooks/useBackgroundJobsStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -import { backgroundJobSchema, type BackgroundJob } from '@/lib/schemas' +import type {BackgroundJob} from '@/lib/schemas'; +import { backgroundJobSchema } from '@/lib/schemas' interface BackgroundJobsState { jobs: Record diff --git a/src/hooks/useStreamingChat.test.tsx b/src/hooks/useStreamingChat.test.tsx index f57568b..6af13ca 100644 --- a/src/hooks/useStreamingChat.test.tsx +++ b/src/hooks/useStreamingChat.test.tsx @@ -1147,7 +1147,7 @@ describe('useStreamingChat', () => { const responseCreatedEvent = JSON.stringify({ type: 'response.created', response: { - id: 'bg-job-123', + id: 'resp_1234567890abcdef1234567890abcdef12345678', }, }) @@ -1180,7 +1180,7 @@ describe('useStreamingChat', () => { expect(result.current.streamBuffer[0]).toEqual({ type: 'assistant', - id: 'bg-job-123', + id: 'resp_1234567890abcdef1234567890abcdef12345678', content: "Background job started. Streaming the response in, but you can view it in the 'Background Jobs' if you leave.", }) diff --git a/src/lib/background-jobs.test.ts b/src/lib/background-jobs.test.ts index 178365b..93c5242 100644 --- a/src/lib/background-jobs.test.ts +++ b/src/lib/background-jobs.test.ts @@ -35,8 +35,14 @@ describe('handleJobStatusRequest', () => { try { await handleJobStatusRequest({}) } catch (error) { - expect(error).toBeInstanceOf(BackgroundJobError) - expect(error.statusCode).toBe(400) + if (error instanceof BackgroundJobError) { + expect(error.statusCode).toBe(400) + } else { + throw new Error( + 'Expected BackgroundJobError, got ' + + (error?.constructor?.name ?? typeof error), + ) + } } }) @@ -48,8 +54,14 @@ describe('handleJobStatusRequest', () => { try { await handleJobStatusRequest({ id: '' }) } catch (error) { - expect(error).toBeInstanceOf(BackgroundJobError) - expect(error.statusCode).toBe(400) + if (error instanceof BackgroundJobError) { + expect(error.statusCode).toBe(400) + } else { + throw new Error( + 'Expected BackgroundJobError, got ' + + (error?.constructor?.name ?? typeof error), + ) + } } }) @@ -58,10 +70,14 @@ describe('handleJobStatusRequest', () => { status: 'running', }) - const result = await handleJobStatusRequest({ id: 'valid-job-id' }) + const result = await handleJobStatusRequest({ + id: 'resp_1234567890abcdef1234567890abcdef12345678', + }) expect(result.status).toBe('running') - expect(mockOpenAI.responses.retrieve).toHaveBeenCalledWith('valid-job-id') + expect(mockOpenAI.responses.retrieve).toHaveBeenCalledWith( + 'resp_1234567890abcdef1234567890abcdef12345678', + ) }) }) @@ -71,7 +87,9 @@ describe('handleJobStatusRequest', () => { status: 'completed', }) - const result = await handleJobStatusRequest({ id: 'completed-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_abcdef1234567890abcdef1234567890abcdef12', + }) expect(result.status).toBe('completed') expect(result).toHaveProperty('completedAt') @@ -83,7 +101,9 @@ describe('handleJobStatusRequest', () => { status: 'failed', }) - const result = await handleJobStatusRequest({ id: 'failed-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_fedcba0987654321fedcba0987654321fedcba09', + }) expect(result.status).toBe('failed') expect(result.error).toBe('Background job failed') @@ -94,7 +114,9 @@ describe('handleJobStatusRequest', () => { status: 'in_progress', }) - const result = await handleJobStatusRequest({ id: 'running-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result.status).toBe('running') }) @@ -104,7 +126,9 @@ describe('handleJobStatusRequest', () => { status: 'queued', }) - const result = await handleJobStatusRequest({ id: 'queued-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result.status).toBe('running') }) @@ -121,7 +145,9 @@ describe('handleJobStatusRequest', () => { apiError.status = 404 mockOpenAI.responses.retrieve.mockRejectedValue(apiError) - const result = await handleJobStatusRequest({ id: 'missing-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result.status).toBe('failed') expect(result.error).toBe('Background job not found') @@ -137,7 +163,9 @@ describe('handleJobStatusRequest', () => { apiError.status = 401 mockOpenAI.responses.retrieve.mockRejectedValue(apiError) - const result = await handleJobStatusRequest({ id: 'unauthorized-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result.status).toBe('failed') expect(result.error).toBe('Authentication failed') @@ -153,7 +181,9 @@ describe('handleJobStatusRequest', () => { apiError.status = 403 mockOpenAI.responses.retrieve.mockRejectedValue(apiError) - const result = await handleJobStatusRequest({ id: 'forbidden-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result.status).toBe('failed') expect(result.error).toBe('Access denied') @@ -169,7 +199,9 @@ describe('handleJobStatusRequest', () => { apiError.status = 429 mockOpenAI.responses.retrieve.mockRejectedValue(apiError) - const result = await handleJobStatusRequest({ id: 'rate-limited-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result.status).toBe('failed') expect(result.error).toBe('Rate limit exceeded') @@ -184,7 +216,9 @@ describe('handleJobStatusRequest', () => { ) mockOpenAI.responses.retrieve.mockRejectedValue(apiError) - const result = await handleJobStatusRequest({ id: 'server-error-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result.status).toBe('failed') expect(result.error).toBe('Failed to check background job status') @@ -200,7 +234,9 @@ describe('handleJobStatusRequest', () => { apiError.status = undefined mockOpenAI.responses.retrieve.mockRejectedValue(apiError) - const result = await handleJobStatusRequest({ id: 'unknown-error-job' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result.status).toBe('failed') expect(result.error).toBe('Failed to check background job status') @@ -213,15 +249,25 @@ describe('handleJobStatusRequest', () => { mockOpenAI.responses.retrieve.mockRejectedValue(networkError) await expect( - handleJobStatusRequest({ id: 'network-error-job' }), + handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }), ).rejects.toThrow(BackgroundJobError) try { - await handleJobStatusRequest({ id: 'network-error-job' }) + await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) } catch (error) { - expect(error).toBeInstanceOf(BackgroundJobError) - expect(error.statusCode).toBe(500) - expect(error.message).toBe('Internal server 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), + ) + } } }) @@ -234,8 +280,14 @@ describe('handleJobStatusRequest', () => { try { await handleJobStatusRequest('{ invalid json') } catch (error) { - expect(error).toBeInstanceOf(BackgroundJobError) - expect(error.statusCode).toBe(400) + if (error instanceof BackgroundJobError) { + expect(error.statusCode).toBe(400) + } else { + throw new Error( + 'Expected BackgroundJobError, got ' + + (error?.constructor?.name ?? typeof error), + ) + } } }) }) @@ -247,7 +299,9 @@ describe('handleJobStatusRequest', () => { }) const beforeTime = new Date().toISOString() - const result = await handleJobStatusRequest({ id: 'timestamp-test' }) + const result = await handleJobStatusRequest({ + id: 'resp_6880e725623081a1af3dc14ba0d562620d62da8686c56bdd', + }) expect(result).toHaveProperty('completedAt') expect(new Date(result.completedAt!).getTime()).toBeGreaterThanOrEqual( diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index e72e722..6405820 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -74,7 +74,12 @@ export const chatRequestSchema = z.object({ store: z.boolean().optional().default(false), }) -const id = z.string().min(1, 'Background job ID is required') +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, From 091a4ebcc98320dffb0eefd564b3081f7f0f665f Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 11:43:11 -0400 Subject: [PATCH 18/19] fix: cancel active streams and clear messages on background job start --- src/components/Chat.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index 4eb7544..13c883f 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -227,6 +227,13 @@ export function Chat() { } 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(), { @@ -241,7 +248,7 @@ export function Chat() { handleError(new Error('Failed to load background job')) } }, - [backgroundJobs], + [backgroundJobs, cancelStream, stop, clearBuffer, setMessages, handleResponse, handleError], ) const handleCancelJob = useCallback((jobId: string) => { From e9599d43f33808ba2cd47c67206a79a3682eedd6 Mon Sep 17 00:00:00 2001 From: Nick Taylor Date: Wed, 23 Jul 2025 16:03:40 -0400 Subject: [PATCH 19/19] chore: formatting fixes --- src/components/Chat.tsx | 10 +++++++++- src/hooks/useBackgroundJobsStore.ts | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index 13c883f..0cfd723 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -248,7 +248,15 @@ export function Chat() { handleError(new Error('Failed to load background job')) } }, - [backgroundJobs, cancelStream, stop, clearBuffer, setMessages, handleResponse, handleError], + [ + backgroundJobs, + cancelStream, + stop, + clearBuffer, + setMessages, + handleResponse, + handleError, + ], ) const handleCancelJob = useCallback((jobId: string) => { diff --git a/src/hooks/useBackgroundJobsStore.ts b/src/hooks/useBackgroundJobsStore.ts index 0b9a883..cc53499 100644 --- a/src/hooks/useBackgroundJobsStore.ts +++ b/src/hooks/useBackgroundJobsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -import type {BackgroundJob} from '@/lib/schemas'; -import { backgroundJobSchema } from '@/lib/schemas' +import type { BackgroundJob } from '@/lib/schemas' +import { backgroundJobSchema } from '@/lib/schemas' interface BackgroundJobsState { jobs: Record