From d3d69b2d56ca42690eb4e7b1293c265e3cddc692 Mon Sep 17 00:00:00 2001 From: Ali AL OGAILI Date: Tue, 24 Mar 2026 14:08:12 +0100 Subject: [PATCH] feat: parse chief_analyst report in useRunStream reducer --- .../features/run-detail/useRunStream.test.ts | 75 +++++++++++++++++++ ui/features/run-detail/hooks/useRunStream.ts | 12 ++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/ui/__tests__/features/run-detail/useRunStream.test.ts b/ui/__tests__/features/run-detail/useRunStream.test.ts index b7b62334..481c51bf 100644 --- a/ui/__tests__/features/run-detail/useRunStream.test.ts +++ b/ui/__tests__/features/run-detail/useRunStream.test.ts @@ -174,3 +174,78 @@ test('completed-run hydration populates tokens from getRun().token_usage without expect(result.current.tokensByStep['market_analyst']).toEqual({ in: 1200, out: 400 }) expect(result.current.tokensTotal).toEqual({ in: 1200, out: 400 }) }) + +test('AGENT_COMPLETE for chief_analyst parses JSON into chiefAnalystReport', async () => { + const { getRun } = jest.requireMock('@/lib/api-client') + const { createSSEConnection } = jest.requireMock('@/lib/sse') + jest.clearAllMocks() + + getRun.mockResolvedValueOnce({ + id: 'ca1', ticker: 'AAPL', date: '2024-01-15', status: 'queued', + decision: null, created_at: '2024-01-15T00:00:00Z', + config: null, reports: {}, error: null, token_usage: null, + }) + + const reportPayload = JSON.stringify({ + verdict: 'BUY', + catalyst: 'Strong Q4 earnings', + execution: 'Enter at market, SL at 180', + tail_risk: 'Rate hike risk', + }) + + createSSEConnection.mockImplementationOnce( + (_url: string, handlers: Record void>) => { + setTimeout(() => { + handlers.onAgentStart?.({ step: 'chief_analyst', turn: 0 }) + handlers.onAgentComplete?.({ step: 'chief_analyst', turn: 0, report: reportPayload }) + handlers.onRunComplete?.({ decision: 'BUY', run_id: 'ca1' }) + }, 0) + return jest.fn() + } + ) + + const { result } = renderHook(() => useRunStream('ca1')) + await act(async () => { await new Promise((r) => setTimeout(r, 10)) }) + + expect(result.current.chiefAnalystReport).toEqual({ + verdict: 'BUY', + catalyst: 'Strong Q4 earnings', + execution: 'Enter at market, SL at 180', + tail_risk: 'Rate hike risk', + }) + // Raw JSON must also land in reports['chief_analyst'] + expect(result.current.reports['chief_analyst']).toEqual([reportPayload]) +}) + +test('AGENT_COMPLETE for chief_analyst with invalid JSON sets chiefAnalystReport to null', async () => { + const { getRun } = jest.requireMock('@/lib/api-client') + const { createSSEConnection } = jest.requireMock('@/lib/sse') + jest.clearAllMocks() + + getRun.mockResolvedValueOnce({ + id: 'ca2', ticker: 'AAPL', date: '2024-01-15', status: 'queued', + decision: null, created_at: '2024-01-15T00:00:00Z', + config: null, reports: {}, error: null, token_usage: null, + }) + + createSSEConnection.mockImplementationOnce( + (_url: string, handlers: Record void>) => { + setTimeout(() => { + handlers.onAgentStart?.({ step: 'chief_analyst', turn: 0 }) + handlers.onAgentComplete?.({ step: 'chief_analyst', turn: 0, report: 'not-valid-json' }) + handlers.onRunComplete?.({ decision: 'HOLD', run_id: 'ca2' }) + }, 0) + return jest.fn() + } + ) + + const { result } = renderHook(() => useRunStream('ca2')) + await act(async () => { await new Promise((r) => setTimeout(r, 10)) }) + + expect(result.current.chiefAnalystReport).toBeNull() +}) + +test('initial chiefAnalystReport is null', () => { + const { result } = renderHook(() => useRunStream('abc')) + expect(result.current.chiefAnalystReport).toBeNull() +}) diff --git a/ui/features/run-detail/hooks/useRunStream.ts b/ui/features/run-detail/hooks/useRunStream.ts index d4394163..b9beb578 100644 --- a/ui/features/run-detail/hooks/useRunStream.ts +++ b/ui/features/run-detail/hooks/useRunStream.ts @@ -4,6 +4,7 @@ import { createSSEConnection } from '@/lib/sse' import { getRun, getRunStreamUrl } from '@/lib/api-client' import { AGENT_STEPS } from '@/lib/types/run' import type { AgentStep } from '@/lib/types/run' +import type { ChiefAnalystReport } from '@/lib/types/agents' import type { RunStreamState, TokenCount } from '../types' const zeroTokens = (): TokenCount => ({ in: 0, out: 0 }) @@ -11,11 +12,12 @@ const zeroTokens = (): TokenCount => ({ in: 0, out: 0 }) const initialState: RunStreamState = { status: 'connecting', steps: Object.fromEntries(AGENT_STEPS.map((s) => [s, 'pending'])) as RunStreamState['steps'], - reports: Object.fromEntries(AGENT_STEPS.map((s) => [s, []])) as RunStreamState['reports'], + reports: Object.fromEntries(AGENT_STEPS.map((s) => [s, []])) as unknown as RunStreamState['reports'], tokensByStep: Object.fromEntries(AGENT_STEPS.map((s) => [s, zeroTokens()])) as RunStreamState['tokensByStep'], tokensTotal: zeroTokens(), verdict: null, error: null, + chiefAnalystReport: null, } type Action = @@ -38,6 +40,13 @@ function reducer(state: RunStreamState, action: Action): RunStreamState { const dIn = action.tokens_in ?? 0 const dOut = action.tokens_out ?? 0 const prev = state.tokensByStep[action.step] ?? zeroTokens() + + let chiefAnalystReport: ChiefAnalystReport | null = state.chiefAnalystReport + if (action.step === 'chief_analyst') { + try { chiefAnalystReport = JSON.parse(action.report) as ChiefAnalystReport } + catch { chiefAnalystReport = null } + } + return { ...state, steps: { ...state.steps, [action.step]: 'done' }, @@ -53,6 +62,7 @@ function reducer(state: RunStreamState, action: Action): RunStreamState { in: state.tokensTotal.in + dIn, out: state.tokensTotal.out + dOut, }, + chiefAnalystReport, } }