Spaces:
Sleeping
Sleeping
natnael kahssay
feat: use real moav2 source as RL task suite — symlinked sandbox, 3 real service tasks
ce25387 | /** | |
| * Scroll behavior tests for AgentBuffer. | |
| * | |
| * The AgentBuffer component uses a useEffect that calls | |
| * messagesEndRef.current?.scrollIntoView({ behavior }) where behavior | |
| * is 'instant' during streaming and 'smooth' otherwise. | |
| * | |
| * We also test the pure helper `systemMessageClass` which classifies | |
| * system message content for CSS styling. | |
| */ | |
| import { describe, it, expect, vi, beforeEach } from 'vitest' | |
| import { isRetryableError } from '../core/services/retry' | |
| // --------------------------------------------------------------------------- | |
| // 1. systemMessageClass — pure function extracted from AgentBuffer.tsx | |
| // --------------------------------------------------------------------------- | |
| // The function is defined inline in AgentBuffer.tsx (not exported), so we | |
| // replicate it here for direct unit testing. If it ever gets extracted and | |
| // exported, swap this for a direct import. | |
| function systemMessageClass(content: string): string { | |
| const lower = content.toLowerCase() | |
| if ( | |
| lower.includes('error') || | |
| lower.includes('failed') || | |
| lower.includes('not ready') || | |
| lower.includes('not found') || | |
| lower.includes('denied') || | |
| lower.includes('authentication') || | |
| lower.includes('not configured') || | |
| lower.includes('no api key') | |
| ) { | |
| return 'system-error' | |
| } | |
| if (lower.includes('retrying')) { | |
| return 'system-retry' | |
| } | |
| return '' | |
| } | |
| describe('systemMessageClass', () => { | |
| it('returns "system-error" for messages containing "error"', () => { | |
| expect(systemMessageClass('Something went wrong: error')).toBe('system-error') | |
| }) | |
| it('returns "system-error" for "failed"', () => { | |
| expect(systemMessageClass('Request failed with status 500')).toBe('system-error') | |
| }) | |
| it('returns "system-error" for "not ready"', () => { | |
| expect(systemMessageClass('Agent is not ready')).toBe('system-error') | |
| }) | |
| it('returns "system-error" for "not found"', () => { | |
| expect(systemMessageClass('Model not found')).toBe('system-error') | |
| }) | |
| it('returns "system-error" for "denied"', () => { | |
| expect(systemMessageClass('Permission denied')).toBe('system-error') | |
| }) | |
| it('returns "system-error" for "authentication"', () => { | |
| expect(systemMessageClass('Authentication failed')).toBe('system-error') | |
| }) | |
| it('returns "system-error" for "not configured"', () => { | |
| expect(systemMessageClass('Provider not configured')).toBe('system-error') | |
| }) | |
| it('returns "system-error" for "no api key"', () => { | |
| expect(systemMessageClass('No API key set')).toBe('system-error') | |
| }) | |
| it('returns "system-retry" for "retrying"', () => { | |
| expect(systemMessageClass('Retrying... (attempt 2/3)')).toBe('system-retry') | |
| }) | |
| it('returns empty string for neutral system messages', () => { | |
| expect(systemMessageClass('Session started')).toBe('') | |
| }) | |
| it('is case-insensitive', () => { | |
| expect(systemMessageClass('ERROR: something broke')).toBe('system-error') | |
| expect(systemMessageClass('RETRYING connection')).toBe('system-retry') | |
| }) | |
| it('prioritizes error over retry when both present', () => { | |
| // "error" check comes first in the if-chain | |
| expect(systemMessageClass('Error: retrying...')).toBe('system-error') | |
| }) | |
| }) | |
| // --------------------------------------------------------------------------- | |
| // 2. Scroll behavior logic — unit test of the scroll decision | |
| // --------------------------------------------------------------------------- | |
| // The actual useEffect in AgentBuffer does: | |
| // messagesEndRef.current?.scrollIntoView({ | |
| // behavior: state.isStreaming ? 'instant' : 'smooth' | |
| // }) | |
| // | |
| // We test the logic that determines scroll behavior separately from the | |
| // React component to keep these tests fast and deterministic. | |
| describe('scroll behavior decision', () => { | |
| function resolveScrollBehavior(isStreaming: boolean): ScrollBehavior { | |
| return isStreaming ? 'instant' : 'smooth' | |
| } | |
| it('uses instant scroll during streaming', () => { | |
| expect(resolveScrollBehavior(true)).toBe('instant') | |
| }) | |
| it('uses smooth scroll after streaming ends', () => { | |
| expect(resolveScrollBehavior(false)).toBe('smooth') | |
| }) | |
| }) | |
| // --------------------------------------------------------------------------- | |
| // 3. Scroll trigger conditions — the effect depends on specific state | |
| // --------------------------------------------------------------------------- | |
| describe('scroll trigger conditions', () => { | |
| // The useEffect dependency array is: | |
| // [state.messages.length, state.streamingBlocks.length, state.isStreaming] | |
| // We simulate state transitions and verify the effect would fire. | |
| interface ScrollState { | |
| messagesLength: number | |
| streamingBlocksLength: number | |
| isStreaming: boolean | |
| } | |
| function shouldScroll(prev: ScrollState, next: ScrollState): boolean { | |
| // The effect fires when any dep changes | |
| return ( | |
| prev.messagesLength !== next.messagesLength || | |
| prev.streamingBlocksLength !== next.streamingBlocksLength || | |
| prev.isStreaming !== next.isStreaming | |
| ) | |
| } | |
| it('triggers scroll when a new message is added', () => { | |
| const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false } | |
| const next: ScrollState = { messagesLength: 3, streamingBlocksLength: 0, isStreaming: false } | |
| expect(shouldScroll(prev, next)).toBe(true) | |
| }) | |
| it('triggers scroll when streaming blocks update', () => { | |
| const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 1, isStreaming: true } | |
| const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 2, isStreaming: true } | |
| expect(shouldScroll(prev, next)).toBe(true) | |
| }) | |
| it('triggers scroll when streaming state changes', () => { | |
| const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 3, isStreaming: true } | |
| const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 3, isStreaming: false } | |
| expect(shouldScroll(prev, next)).toBe(true) | |
| }) | |
| it('does not trigger scroll when nothing changes', () => { | |
| const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false } | |
| const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false } | |
| expect(shouldScroll(prev, next)).toBe(false) | |
| }) | |
| }) | |
| // --------------------------------------------------------------------------- | |
| // 4. Session switch — empty session hero text | |
| // --------------------------------------------------------------------------- | |
| describe('empty session detection', () => { | |
| // In AgentBuffer, the hero text is shown when: | |
| // const hasContent = state.messages.length > 0 || state.streamingBlocks.length > 0 | |
| // {!hasContent ? <div className="empty-chat"> ... } | |
| function hasContent(messagesLength: number, streamingBlocksLength: number): boolean { | |
| return messagesLength > 0 || streamingBlocksLength > 0 | |
| } | |
| it('shows hero text when no messages and no streaming blocks', () => { | |
| expect(hasContent(0, 0)).toBe(false) | |
| }) | |
| it('hides hero text when messages exist', () => { | |
| expect(hasContent(1, 0)).toBe(true) | |
| }) | |
| it('hides hero text when streaming blocks exist', () => { | |
| expect(hasContent(0, 1)).toBe(true) | |
| }) | |
| it('hides hero text when both exist', () => { | |
| expect(hasContent(3, 2)).toBe(true) | |
| }) | |
| }) | |
| // --------------------------------------------------------------------------- | |
| // 5. SessionStore (unit tests of subscribe/notify) | |
| // --------------------------------------------------------------------------- | |
| describe('SessionStore subscribe/notify', () => { | |
| // We test the core subscribe/notify contract without importing the real | |
| // SessionStore (which has heavy dependencies on agent-service, db, etc.) | |
| class MinimalStore { | |
| private listeners = new Set<() => void>() | |
| subscribe(listener: () => void): () => void { | |
| this.listeners.add(listener) | |
| return () => this.listeners.delete(listener) | |
| } | |
| notify() { | |
| for (const listener of this.listeners) listener() | |
| } | |
| } | |
| it('notifies all subscribers', () => { | |
| const store = new MinimalStore() | |
| const fn1 = vi.fn() | |
| const fn2 = vi.fn() | |
| store.subscribe(fn1) | |
| store.subscribe(fn2) | |
| store.notify() | |
| expect(fn1).toHaveBeenCalledOnce() | |
| expect(fn2).toHaveBeenCalledOnce() | |
| }) | |
| it('unsubscribe removes the listener', () => { | |
| const store = new MinimalStore() | |
| const fn = vi.fn() | |
| const unsub = store.subscribe(fn) | |
| unsub() | |
| store.notify() | |
| expect(fn).not.toHaveBeenCalled() | |
| }) | |
| it('multiple unsubscribes are idempotent', () => { | |
| const store = new MinimalStore() | |
| const fn = vi.fn() | |
| const unsub = store.subscribe(fn) | |
| unsub() | |
| unsub() // second call should be harmless | |
| store.notify() | |
| expect(fn).not.toHaveBeenCalled() | |
| }) | |
| }) | |
| // --------------------------------------------------------------------------- | |
| // 6. isRetryableError shared helper | |
| // --------------------------------------------------------------------------- | |
| describe('isRetryableError', () => { | |
| it('identifies rate limit errors as transient', () => { | |
| expect(isRetryableError({ message: 'Error 429: rate limit exceeded' })).toBe(true) | |
| expect(isRetryableError({ message: 'rate limit exceeded' })).toBe(true) | |
| }) | |
| it('identifies server errors as transient', () => { | |
| expect(isRetryableError({ message: 'Internal server error 500' })).toBe(true) | |
| expect(isRetryableError({ message: 'Bad gateway 502' })).toBe(true) | |
| expect(isRetryableError({ message: 'Service unavailable 503' })).toBe(true) | |
| }) | |
| it('identifies network errors as transient', () => { | |
| expect(isRetryableError({ message: 'fetch failed' })).toBe(true) | |
| expect(isRetryableError({ message: 'ECONNREFUSED' })).toBe(true) | |
| expect(isRetryableError({ message: 'ECONNRESET' })).toBe(true) | |
| }) | |
| it('identifies timeout errors as transient', () => { | |
| expect(isRetryableError({ message: 'Request timed out' })).toBe(true) | |
| }) | |
| it('identifies overloaded errors as transient', () => { | |
| expect(isRetryableError({ message: 'Server overloaded' })).toBe(true) | |
| expect(isRetryableError({ message: 'At capacity' })).toBe(true) | |
| }) | |
| it('does NOT treat auth errors as transient', () => { | |
| expect(isRetryableError({ message: 'Invalid API key (401)' })).toBe(false) | |
| expect(isRetryableError({ message: 'Forbidden (403)' })).toBe(false) | |
| }) | |
| it('does NOT treat model-not-found as transient', () => { | |
| expect(isRetryableError({ message: 'Model does not exist' })).toBe(false) | |
| }) | |
| it('handles non-Error objects gracefully', () => { | |
| expect(isRetryableError('timeout')).toBe(true) | |
| expect(isRetryableError('something unknown')).toBe(false) | |
| }) | |
| }) | |