Files
resolutionflow/frontend/src/api/aiSessions.ts
Michael Chihlas 0f00ee5e01 feat(escalations): close out plan-locked wedge polish
Four items from the design-plan audit, all flagged as locked-design or
Codex corrections, shipped together so the GTM demo path covers them
end-to-end before bug bash.

1. Live AI assessment refresh on the magic-moment screen. Backend already
   publishes handoff_assessment_ready when enrich_escalation_async commits;
   wire the frontend listener so the senior sees the assessment populate
   without a manual reopen. New event type + onAssessmentReady handler on
   streamEscalations; AssistantChatPage opens a scoped SSE subscription
   whenever it tracks a handoff missing its assessment, refetches on match,
   and replaces magicHandoff / overlayHandoff in place. Closes the loop on
   the async-assessment commit e8ba74e.

2. Suggested-step chips below the chat input. Locked design from the plan
   (Codex correction). Chip strip renders above the composer post-claim
   when ai_assessment_data.suggested_steps[] is non-empty. Click prefills
   the input and focuses; first send or explicit X hides for the session.

3. Unread 6px dot on EscalationQueue cards. localStorage-persisted seen
   set (rf-escalation-seen, capped 200). Dot top-right when not seen.
   Cleared on open (card click) or claim (Pick Up) — NOT on hover, per
   Codex correction. Pick Up stops propagation so it doesn't double-fire.

4. Race-condition toast on claim conflict. The /claim endpoint previously
   silently overwrote claimed_by — both seniors thought they owned the
   session. New HandoffAlreadyClaimedError carries the winner's id/name/
   timestamp; claim_session rejects different-user re-claims (same-user is
   idempotent for double-click safety); endpoint returns 409 with
   structured detail. AssistantChatPage.handleStartHere extracts and
   surfaces "Already claimed by {name} {time_ago}." via toast, drops
   ?pickup=true, dismisses magic-moment so the loser flows back to queue.

Tests: 2 new unit tests in test_handoff_manager.py (conflict raises,
same-user idempotent). Full handoff + escalation suite (34 tests) green.
Frontend tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 01:59:28 -04:00

324 lines
10 KiB
TypeScript

import apiClient from './client'
import type {
AISessionCreateRequest,
AISessionCreateResponse,
StepResponseRequest,
StepResponseResponse,
ResolveSessionRequest,
EscalateSessionRequest,
SessionCloseResponse,
SessionDocumentation,
AISessionSummary,
AISessionDetail,
AISessionSearchResult,
SimilarSession,
PickupSessionRequest,
StatusUpdateRequest,
StatusUpdateResponse,
ChatSessionCreateResponse,
ChatMessageRequest,
ChatMessageResponse,
HandoffCreatedEvent,
HandoffAssessmentReadyEvent,
EscalationStreamHandlers,
} from '@/types/ai-session'
export const aiSessionsApi = {
async createSession(data: AISessionCreateRequest): Promise<AISessionCreateResponse> {
const response = await apiClient.post<AISessionCreateResponse>('/ai-sessions', data)
return response.data
},
async createChatSession(data: AISessionCreateRequest): Promise<ChatSessionCreateResponse> {
const response = await apiClient.post<ChatSessionCreateResponse>('/ai-sessions', {
...data,
session_type: 'chat',
})
return response.data
},
async sendChatMessage(sessionId: string, data: ChatMessageRequest): Promise<ChatMessageResponse> {
const response = await apiClient.post<ChatMessageResponse>(
`/ai-sessions/${sessionId}/chat`,
data
)
return response.data
},
async respondToStep(sessionId: string, data: StepResponseRequest): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/respond`,
data
)
return response.data
},
async resolveSession(sessionId: string, data: ResolveSessionRequest): Promise<SessionCloseResponse> {
const response = await apiClient.post<SessionCloseResponse>(
`/ai-sessions/${sessionId}/resolve`,
data
)
return response.data
},
async escalateSession(sessionId: string, data: EscalateSessionRequest): Promise<SessionCloseResponse> {
const response = await apiClient.post<SessionCloseResponse>(
`/ai-sessions/${sessionId}/escalate`,
data
)
return response.data
},
async listSessions(params?: {
status?: string
session_type?: string
skip?: number
limit?: number
problem_domain?: string
matched_flow_id?: string
confidence_tier?: string
ticket_id?: string
date_from?: string
date_to?: string
q?: string
}): Promise<AISessionSummary[]> {
// Strip empty string values so they aren't sent as empty query params
const cleanParams = params
? Object.fromEntries(Object.entries(params).filter(([, v]) => v !== '' && v !== undefined))
: undefined
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions', { params: cleanParams })
return response.data
},
async getSession(sessionId: string): Promise<AISessionDetail> {
const response = await apiClient.get<AISessionDetail>(`/ai-sessions/${sessionId}`)
return response.data
},
async getDocumentation(sessionId: string): Promise<SessionDocumentation> {
const response = await apiClient.get<SessionDocumentation>(
`/ai-sessions/${sessionId}/documentation`
)
return response.data
},
async streamDocumentation(
sessionId: string,
onChunk: (text: string) => void,
onDone: () => void,
onError: (error: string) => void,
): Promise<void> {
const token = localStorage.getItem('access_token')
const baseUrl = import.meta.env.VITE_API_URL || ''
try {
const response = await fetch(
`${baseUrl}/api/v1/ai-sessions/${sessionId}/documentation/stream`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)
if (!response.ok) {
onError(`HTTP ${response.status}`)
return
}
const reader = response.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
onDone()
return
}
if (data.startsWith('[ERROR]')) {
onError(data.slice(8))
return
}
onChunk(data)
}
}
}
// Stream ended without [DONE]
onDone()
} catch (err) {
onError(err instanceof Error ? err.message : 'Stream failed')
}
},
async rateSession(sessionId: string, data: { rating: number; feedback?: string }): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/rate`, data)
},
async retryPsaPush(sessionId: string): Promise<{ psa_push_status: string; psa_push_error: string | null }> {
const response = await apiClient.post<{ psa_push_status: string; psa_push_error: string | null }>(
`/ai-sessions/${sessionId}/retry-psa-push`
)
return response.data
},
async saveTaskLane(sessionId: string, data: {
questions: Array<{ text: string; context?: string }>;
actions: Array<{ label: string; command?: string | null; description?: string }>;
responses: Array<Record<string, unknown>>;
}): Promise<void> {
await apiClient.put(`/ai-sessions/${sessionId}/task-lane`, data)
},
async pauseSession(sessionId: string): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/pause`)
},
async resumeSession(sessionId: string): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/resume`)
},
async abandonSession(sessionId: string, reason?: string): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/abandon`, null, {
params: reason ? { reason } : undefined,
})
},
async deleteSession(sessionId: string): Promise<void> {
await apiClient.delete(`/ai-sessions/${sessionId}`)
},
async pickupSession(sessionId: string, data: PickupSessionRequest): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/pickup`,
data
)
return response.data
},
async linkTicket(sessionId: string, data: { psa_ticket_id: string; psa_connection_id: string }): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/link-ticket`,
data
)
return response.data
},
async getEscalationQueue(): Promise<AISessionSummary[]> {
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue')
return response.data
},
// Native EventSource cannot send Authorization headers, so we use fetch +
// ReadableStream and parse SSE frames manually (same pattern as
// `streamDocumentation`). The returned promise resolves on clean stream
// close (server hangs up) and rejects on network/HTTP error so the caller
// can decide whether to reconnect with backoff.
async streamEscalations(
handlers: EscalationStreamHandlers,
signal: AbortSignal,
): Promise<void> {
const token = localStorage.getItem('access_token')
const baseUrl = import.meta.env.VITE_API_URL || ''
const response = await fetch(
`${baseUrl}/api/v1/ai-sessions/escalations/stream`,
{
headers: { Authorization: `Bearer ${token}` },
signal,
},
)
if (!response.ok) {
throw new Error(`Escalation stream failed: HTTP ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Escalation stream: no response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) return
buffer += decoder.decode(value, { stream: true })
// SSE frames are separated by blank lines. Hold the trailing partial
// frame in the buffer until the next chunk completes it.
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
if (!frame) continue
let eventType = 'message'
let data = ''
for (const line of frame.split('\n')) {
if (line.startsWith(':')) continue // comment / keepalive
if (line.startsWith('event: ')) eventType = line.slice(7).trim()
else if (line.startsWith('data: ')) data += line.slice(6)
}
if (!data) continue
try {
const parsed = JSON.parse(data) as Record<string, unknown>
if (eventType === 'handoff_created' && parsed.type === 'handoff_created') {
handlers.onHandoffCreated?.(parsed as unknown as HandoffCreatedEvent)
} else if (
eventType === 'handoff_assessment_ready' &&
parsed.type === 'handoff_assessment_ready'
) {
handlers.onAssessmentReady?.(
parsed as unknown as HandoffAssessmentReadyEvent,
)
} else if (eventType === 'ready') {
handlers.onReady?.()
}
} catch {
// skip malformed frame
}
}
}
},
async search(q: string, limit: number = 5): Promise<AISessionSearchResult[]> {
const response = await apiClient.get<AISessionSearchResult[]>('/ai-sessions/search', {
params: { q, limit },
})
return response.data
},
async getSimilar(sessionId: string, limit: number = 5): Promise<SimilarSession[]> {
const response = await apiClient.get<SimilarSession[]>(`/ai-sessions/${sessionId}/similar`, {
params: { limit },
})
return response.data
},
async generateStatusUpdate(sessionId: string, data: StatusUpdateRequest): Promise<StatusUpdateResponse> {
const response = await apiClient.post<StatusUpdateResponse>(
`/ai-sessions/${sessionId}/status-update`,
data
)
return response.data
},
}
export default aiSessionsApi