---
phase: 07-chat-infrastructure
plan: 02
type: tdd
---

<objective>
Implement async thunks and REST API integration for chat using TDD methodology.

Purpose: Create API client methods and Redux async thunks for fetching chat history and sending messages. All API interactions must be tested following CLAUDE.md TDD requirements.

Output: REST API methods and 2 async thunks with comprehensive unit tests.
</objective>

<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md

# Phase 6 design documents
@.planning/phases/06-session-chat-research-design/CHAT_API.md
@.planning/phases/06-session-chat-research-design/CHAT_REDUX_DESIGN.md

# Phase 7 Plan 1 outputs
@.planning/phases/07-chat-infrastructure/07-01-SUMMARY.md

# Existing API patterns
@jam-ui/src/helpers/rest.js
@jam-ui/src/store/features/mediaSlice.js

# Codebase conventions
@.planning/codebase/ARCHITECTURE.md
@CLAUDE.md

**Key context from Phase 6:**
- REST API endpoints: GET /api/chat, POST /api/chat
- Request format: { channel, session_id (optional), before (pagination) }
- Response format: { messages: [...], next: number | null }
- Error codes: 404 (not found), 403 (unauthorized), 422 (validation), 500 (server error)

**Existing API patterns from rest.js:**
- HTTP methods: getJSON, postJSON, putJSON, deleteJSON
- Error handling: throw ApiError with status code
- URL construction: baseURL + path

**TDD requirements from CLAUDE.md:**
- Write test FIRST (RED phase)
- Mock fetch API in tests
- Test success and error paths
- Implement minimum code to pass (GREEN phase)
- Refactor while keeping tests green
</context>

<tasks>

<task type="tdd">
  <name>Task 1: Add REST API client methods (TDD)</name>
  <files>jam-ui/src/helpers/rest.js, jam-ui/src/helpers/__tests__/rest.test.js</files>
  <action>
Using TDD methodology, add chat API methods to rest.js:

**RED Phase - Write failing tests first:**

1. Create or extend test file `jam-ui/src/helpers/__tests__/rest.test.js`
2. Write tests for `getChatMessages`:
   ```javascript
   describe('getChatMessages', () => {
     test('fetches messages for session channel', async () => {
       global.fetch = jest.fn().mockResolvedValue({
         ok: true,
         json: async () => ({
           messages: [
             { id: 'msg-1', message: 'Hello', sender_id: 'user-1' }
           ],
           next: 20
         })
       });

       const result = await getChatMessages({
         channel: 'session',
         sessionId: 'session-abc'
       });

       expect(fetch).toHaveBeenCalledWith(
         expect.stringContaining('/api/chat?channel=session&session_id=session-abc'),
         expect.objectContaining({ method: 'GET' })
       );
       expect(result.messages).toHaveLength(1);
       expect(result.next).toBe(20);
     });

     test('fetches messages for global channel', async () => {
       // Test without session_id
     });

     test('includes pagination cursor (before)', async () => {
       // Test with before parameter
       const result = await getChatMessages({
         channel: 'session',
         sessionId: 'session-abc',
         before: 50
       });
       expect(fetch).toHaveBeenCalledWith(
         expect.stringContaining('before=50'),
         expect.any(Object)
       );
     });

     test('throws ApiError on 404', async () => {
       global.fetch = jest.fn().mockResolvedValue({
         ok: false,
         status: 404,
         statusText: 'Not Found'
       });

       await expect(
         getChatMessages({ channel: 'session', sessionId: 'invalid' })
       ).rejects.toThrow('Not Found');
     });

     test('throws ApiError on 403', async () => {
       // Test unauthorized access
     });

     test('throws ApiError on 500', async () => {
       // Test server error
     });
   });
   ```

3. Write tests for `sendChatMessage`:
   ```javascript
   describe('sendChatMessage', () => {
     test('sends message to session channel', async () => {
       global.fetch = jest.fn().mockResolvedValue({
         ok: true,
         json: async () => ({
           message: {
             id: 'msg-new',
             message: 'Hello world',
             sender_id: 'user-1',
             created_at: '2026-01-26T12:00:00Z'
           }
         })
       });

       const result = await sendChatMessage({
         channel: 'session',
         sessionId: 'session-abc',
         message: 'Hello world'
       });

       expect(fetch).toHaveBeenCalledWith(
         expect.stringContaining('/api/chat'),
         expect.objectContaining({
           method: 'POST',
           body: JSON.stringify({
             channel: 'session',
             session_id: 'session-abc',
             message: 'Hello world'
           })
         })
       );
       expect(result.message.id).toBe('msg-new');
     });

     test('sends message to global channel', async () => {
       // Test without session_id
     });

     test('throws ApiError on 422 (validation error)', async () => {
       global.fetch = jest.fn().mockResolvedValue({
         ok: false,
         status: 422,
         statusText: 'Unprocessable Entity',
         json: async () => ({ error: 'Message too long' })
       });

       await expect(
         sendChatMessage({ channel: 'session', sessionId: 'abc', message: 'x'.repeat(1000) })
       ).rejects.toThrow();
     });

     test('throws ApiError on 403', async () => {
       // Test unauthorized
     });

     test('throws ApiError on 500', async () => {
       // Test server error
     });
   });
   ```

4. Run tests - should FAIL (methods don't exist yet)

**GREEN Phase - Implement API methods:**

5. Add methods to `jam-ui/src/helpers/rest.js`:
   ```javascript
   export const getChatMessages = async ({ channel, sessionId, before }) => {
     const params = new URLSearchParams({ channel });
     if (sessionId) params.append('session_id', sessionId);
     if (before) params.append('before', before);

     const response = await fetch(`${baseURL}/api/chat?${params}`, {
       method: 'GET',
       credentials: 'include',
       headers: { 'Content-Type': 'application/json' }
     });

     if (!response.ok) {
       throw new ApiError(response.status, response.statusText);
     }

     return response.json();
   };

   export const sendChatMessage = async ({ channel, sessionId, message }) => {
     const body = { channel, message };
     if (sessionId) body.session_id = sessionId;

     const response = await fetch(`${baseURL}/api/chat`, {
       method: 'POST',
       credentials: 'include',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify(body)
     });

     if (!response.ok) {
       const error = await response.json().catch(() => ({}));
       throw new ApiError(response.status, error.error || response.statusText);
     }

     return response.json();
   };
   ```

6. Run tests - should PASS

**REFACTOR Phase:**

7. Review error handling consistency with existing rest.js methods
8. Add JSDoc comments with parameter types
9. Ensure tests still pass

**Verification:**
- All API method tests pass
- Error handling for all HTTP status codes
- URL construction correct for all scenarios
  </action>
  <verify>
REST API methods implemented with passing unit tests. All HTTP status codes handled. URL construction validated.
  </verify>
  <done>
Chat API client methods complete with TDD validation.
  </done>
</task>

<task type="tdd">
  <name>Task 2: Implement fetchChatHistory async thunk (TDD)</name>
  <files>jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js</files>
  <action>
Using TDD methodology, implement fetchChatHistory async thunk with extra reducers:

**RED Phase - Write failing tests first:**

1. Add tests to `sessionChatSlice.test.js`:
   ```javascript
   import { fetchChatHistory } from '../sessionChatSlice';

   describe('fetchChatHistory async thunk', () => {
     test('sets loading state on pending', () => {
       const action = { type: fetchChatHistory.pending.type, meta: { arg: { channel: 'session-abc' } } };
       const state = {
         fetchStatus: {},
         fetchError: {}
       };
       const newState = sessionChatReducer(state, action);
       expect(newState.fetchStatus['session-abc']).toBe('loading');
       expect(newState.fetchError['session-abc']).toBeNull();
     });

     test('adds messages on fulfilled', () => {
       const action = {
         type: fetchChatHistory.fulfilled.type,
         meta: { arg: { channel: 'session-abc' } },
         payload: {
           messages: [
             { id: 'msg-1', message: 'Hello', sender_id: 'user-1', created_at: '2026-01-26T12:00:00Z' }
           ],
           next: 20
         }
       };
       const state = {
         messagesByChannel: {},
         fetchStatus: { 'session-abc': 'loading' },
         nextCursors: {}
       };
       const newState = sessionChatReducer(state, action);
       expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
       expect(newState.fetchStatus['session-abc']).toBe('succeeded');
       expect(newState.nextCursors['session-abc']).toBe(20);
     });

     test('deduplicates messages on fulfilled', () => {
       // Test that fetched messages don't duplicate existing ones
       const state = {
         messagesByChannel: {
           'session-abc': [{ id: 'msg-1', message: 'Hello' }]
         },
         fetchStatus: { 'session-abc': 'loading' }
       };
       const action = {
         type: fetchChatHistory.fulfilled.type,
         meta: { arg: { channel: 'session-abc' } },
         payload: {
           messages: [
             { id: 'msg-1', message: 'Hello' }, // Duplicate
             { id: 'msg-2', message: 'World' }  // New
           ],
           next: null
         }
       };
       const newState = sessionChatReducer(state, action);
       expect(newState.messagesByChannel['session-abc']).toHaveLength(2);
     });

     test('sets error state on rejected', () => {
       const action = {
         type: fetchChatHistory.rejected.type,
         meta: { arg: { channel: 'session-abc' } },
         error: { message: 'Not Found' }
       };
       const state = {
         fetchStatus: { 'session-abc': 'loading' },
         fetchError: {}
       };
       const newState = sessionChatReducer(state, action);
       expect(newState.fetchStatus['session-abc']).toBe('failed');
       expect(newState.fetchError['session-abc']).toBe('Not Found');
     });

     test('calls API with correct parameters', async () => {
       const mockGetChatMessages = jest.fn().mockResolvedValue({
         messages: [],
         next: null
       });

       const dispatch = jest.fn();
       const getState = jest.fn();
       const thunk = fetchChatHistory({ channel: 'session-abc', sessionId: 'session-abc' });

       // Mock getChatMessages temporarily
       await thunk(dispatch, getState, undefined);

       expect(mockGetChatMessages).toHaveBeenCalledWith({
         channel: 'session',
         sessionId: 'session-abc',
         before: undefined
       });
     });
   });
   ```

2. Run tests - should FAIL (thunk doesn't exist yet)

**GREEN Phase - Implement async thunk:**

3. Add fetchChatHistory to sessionChatSlice.js:
   ```javascript
   import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
   import { getChatMessages } from '../../helpers/rest';

   export const fetchChatHistory = createAsyncThunk(
     'sessionChat/fetchChatHistory',
     async ({ channel, sessionId, before }) => {
       const response = await getChatMessages({ channel, sessionId, before });
       return { channel, ...response };
     }
   );

   const sessionChatSlice = createSlice({
     name: 'sessionChat',
     initialState,
     reducers: {
       // ... existing reducers
     },
     extraReducers: (builder) => {
       builder
         .addCase(fetchChatHistory.pending, (state, action) => {
           const channel = action.meta.arg.channel;
           state.fetchStatus[channel] = 'loading';
           state.fetchError[channel] = null;
         })
         .addCase(fetchChatHistory.fulfilled, (state, action) => {
           const channel = action.meta.arg.channel;
           const { messages, next } = action.payload;

           // Initialize channel if not exists
           if (!state.messagesByChannel[channel]) {
             state.messagesByChannel[channel] = [];
           }

           // Deduplicate messages
           const existingIds = new Set(state.messagesByChannel[channel].map(m => m.id));
           const newMessages = messages.filter(m => !existingIds.has(m.id));

           // Prepend new messages (oldest first for pagination)
           state.messagesByChannel[channel] = [...newMessages, ...state.messagesByChannel[channel]];

           state.fetchStatus[channel] = 'succeeded';
           state.nextCursors[channel] = next;
         })
         .addCase(fetchChatHistory.rejected, (state, action) => {
           const channel = action.meta.arg.channel;
           state.fetchStatus[channel] = 'failed';
           state.fetchError[channel] = action.error.message;
         });
     }
   });
   ```

4. Run tests - should PASS

**REFACTOR Phase:**

5. Extract message deduplication to helper function if needed
6. Add JSDoc comments for thunk parameters
7. Ensure tests still pass

**Verification:**
- All fetchChatHistory tests pass
- Loading states work correctly
- Message deduplication validated
- Error handling works
  </action>
  <verify>
fetchChatHistory async thunk implemented with passing unit tests. All state transitions validated. Message deduplication working.
  </verify>
  <done>
Chat history fetching complete with TDD validation.
  </done>
</task>

<task type="tdd">
  <name>Task 3: Implement sendMessage async thunk with optimistic updates (TDD)</name>
  <files>jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js</files>
  <action>
Using TDD methodology, implement sendMessage async thunk with optimistic UI updates:

**RED Phase - Write failing tests first:**

1. Add tests to `sessionChatSlice.test.js`:
   ```javascript
   import { sendMessage } from '../sessionChatSlice';

   describe('sendMessage async thunk', () => {
     test('sets loading state on pending', () => {
       const action = { type: sendMessage.pending.type };
       const state = {
         sendStatus: 'idle',
         sendError: null
       };
       const newState = sessionChatReducer(state, action);
       expect(newState.sendStatus).toBe('loading');
       expect(newState.sendError).toBeNull();
     });

     test('adds message optimistically on pending', () => {
       const action = {
         type: sendMessage.pending.type,
         meta: {
           arg: {
             channel: 'session-abc',
             message: 'Hello world',
             optimisticId: 'temp-1',
             userId: 'user-1',
             userName: 'John Doe'
           }
         }
       };
       const state = {
         messagesByChannel: { 'session-abc': [] },
         sendStatus: 'idle'
       };
       const newState = sessionChatReducer(state, action);
       expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
       expect(newState.messagesByChannel['session-abc'][0].id).toBe('temp-1');
       expect(newState.messagesByChannel['session-abc'][0].message).toBe('Hello world');
     });

     test('replaces optimistic message with real one on fulfilled', () => {
       const state = {
         messagesByChannel: {
           'session-abc': [
             { id: 'temp-1', message: 'Hello world', sender_id: 'user-1' }
           ]
         },
         sendStatus: 'loading'
       };
       const action = {
         type: sendMessage.fulfilled.type,
         meta: {
           arg: { optimisticId: 'temp-1', channel: 'session-abc' }
         },
         payload: {
           message: {
             id: 'msg-real',
             message: 'Hello world',
             sender_id: 'user-1',
             created_at: '2026-01-26T12:00:00Z'
           }
         }
       };
       const newState = sessionChatReducer(state, action);
       expect(newState.messagesByChannel['session-abc']).toHaveLength(1);
       expect(newState.messagesByChannel['session-abc'][0].id).toBe('msg-real');
       expect(newState.sendStatus).toBe('succeeded');
     });

     test('removes optimistic message on rejected', () => {
       const state = {
         messagesByChannel: {
           'session-abc': [
             { id: 'temp-1', message: 'Hello world', sender_id: 'user-1' }
           ]
         },
         sendStatus: 'loading'
       };
       const action = {
         type: sendMessage.rejected.type,
         meta: {
           arg: { optimisticId: 'temp-1', channel: 'session-abc' }
         },
         error: { message: 'Network error' }
       };
       const newState = sessionChatReducer(state, action);
       expect(newState.messagesByChannel['session-abc']).toHaveLength(0);
       expect(newState.sendStatus).toBe('failed');
       expect(newState.sendError).toBe('Network error');
     });

     test('calls API with correct parameters', async () => {
       const mockSendChatMessage = jest.fn().mockResolvedValue({
         message: { id: 'msg-1', message: 'Hello' }
       });

       const dispatch = jest.fn();
       const getState = jest.fn();
       const thunk = sendMessage({
         channel: 'session-abc',
         sessionId: 'session-abc',
         message: 'Hello'
       });

       await thunk(dispatch, getState, undefined);

       expect(mockSendChatMessage).toHaveBeenCalledWith({
         channel: 'session',
         sessionId: 'session-abc',
         message: 'Hello'
       });
     });
   });
   ```

2. Run tests - should FAIL (thunk doesn't exist yet)

**GREEN Phase - Implement async thunk:**

3. Add sendMessage to sessionChatSlice.js:
   ```javascript
   import { sendChatMessage } from '../../helpers/rest';

   export const sendMessage = createAsyncThunk(
     'sessionChat/sendMessage',
     async ({ channel, sessionId, message }) => {
       const response = await sendChatMessage({ channel, sessionId, message });
       return response;
     }
   );

   // In extraReducers:
   builder
     .addCase(sendMessage.pending, (state, action) => {
       state.sendStatus = 'loading';
       state.sendError = null;

       // Optimistic update: add message immediately
       const { channel, message, optimisticId, userId, userName } = action.meta.arg;
       if (!state.messagesByChannel[channel]) {
         state.messagesByChannel[channel] = [];
       }

       state.messagesByChannel[channel].push({
         id: optimisticId,
         senderId: userId,
         senderName: userName,
         message,
         createdAt: new Date().toISOString(),
         channel,
         isOptimistic: true
       });
     })
     .addCase(sendMessage.fulfilled, (state, action) => {
       state.sendStatus = 'succeeded';
       state.sendError = null;

       // Replace optimistic message with real one
       const { channel, optimisticId } = action.meta.arg;
       const realMessage = action.payload.message;

       const messages = state.messagesByChannel[channel];
       const index = messages.findIndex(m => m.id === optimisticId);
       if (index !== -1) {
         messages[index] = {
           id: realMessage.id,
           senderId: realMessage.sender_id,
           senderName: realMessage.sender_name,
           message: realMessage.message,
           createdAt: realMessage.created_at,
           channel: realMessage.channel
         };
       }
     })
     .addCase(sendMessage.rejected, (state, action) => {
       state.sendStatus = 'failed';
       state.sendError = action.error.message;

       // Remove optimistic message on failure
       const { channel, optimisticId } = action.meta.arg;
       const messages = state.messagesByChannel[channel];
       const index = messages.findIndex(m => m.id === optimisticId);
       if (index !== -1) {
         messages.splice(index, 1);
       }
     });
   ```

4. Run tests - should PASS

**REFACTOR Phase:**

5. Extract optimistic message creation to helper function
6. Add JSDoc comments
7. Ensure tests still pass

**Integration test:**

8. Write integration test showing complete send flow:
   ```javascript
   describe('sendMessage integration', () => {
     test('complete send flow: optimistic → fulfilled → state clean', async () => {
       // Test complete flow from optimistic to final state
     });

     test('complete send flow: optimistic → rejected → rollback', async () => {
       // Test error rollback
     });
   });
   ```

**Verification:**
- All sendMessage tests pass
- Optimistic updates work correctly
- Error rollback validated
- Integration test shows complete flow
  </action>
  <verify>
sendMessage async thunk implemented with passing unit tests. Optimistic UI updates validated. Error rollback working correctly.
  </verify>
  <done>
Complete async thunk implementation with TDD validation. API integration tested for all success and error paths. Ready for Phase 7 Plan 3 (WebSocket Integration).
  </done>
</task>

</tasks>

<verification>
Before declaring plan complete:
- [ ] REST API methods added to rest.js: getChatMessages, sendChatMessage
- [ ] Unit tests for API methods with all HTTP status codes
- [ ] fetchChatHistory async thunk implemented with extra reducers
- [ ] sendMessage async thunk implemented with optimistic updates
- [ ] All async thunk state transitions tested (pending, fulfilled, rejected)
- [ ] Message deduplication works in fetchChatHistory
- [ ] Optimistic UI updates work in sendMessage
- [ ] Error handling validated for all failure scenarios
- [ ] All tests passing (TDD methodology followed)
</verification>

<success_criteria>

- REST API methods implemented and tested
- fetchChatHistory async thunk complete with extra reducers
- sendMessage async thunk complete with optimistic updates
- Unit test coverage 100% for all async operations
- All HTTP error codes handled correctly
- Message deduplication validated
- Optimistic updates and rollback working
- TDD methodology strictly followed (test-first approach)
- Ready for Phase 7 Plan 3 (WebSocket Integration & Selectors)
</success_criteria>

<output>
After completion, create `.planning/phases/07-chat-infrastructure/07-02-SUMMARY.md`:

# Phase 7 Plan 2: Async Thunks & API Integration Summary

**Implemented REST API methods and async thunks using TDD methodology**

## Accomplishments

- Added getChatMessages and sendChatMessage to rest.js
- Implemented fetchChatHistory async thunk with message deduplication
- Implemented sendMessage async thunk with optimistic UI updates
- Comprehensive unit test suite with 100% coverage
- All HTTP error codes handled and tested
- Optimistic updates and error rollback validated

## Files Created/Modified

- `jam-ui/src/helpers/rest.js` - Added chat API methods
- `jam-ui/src/helpers/__tests__/rest.test.js` - API method tests
- `jam-ui/src/store/features/sessionChatSlice.js` - Added async thunks and extra reducers
- `jam-ui/src/store/features/__tests__/sessionChatSlice.test.js` - Async thunk tests

## Decisions Made

[Document any implementation decisions: Optimistic update strategy? Error handling approach? Pagination cursor handling?]

## Issues Encountered

[Any challenges during API integration, or "None"]

## Next Phase Readiness

Ready for Plan 3 (WebSocket Integration & Selectors)
</output>
