diff --git a/src/web-ui/src/flow_chat/hooks/useAcpPlan.test.ts b/src/web-ui/src/flow_chat/hooks/useAcpPlan.test.ts new file mode 100644 index 0000000000..9df8ba47d2 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useAcpPlan.test.ts @@ -0,0 +1,77 @@ +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; + +function renderHook(hook: (props: P) => R, options?: { initialProps: P }) { + const dom = new JSDOM(''); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const root = createRoot(document.createElement('div')); + const result = {} as { current: R }; + let props = options?.initialProps as P; + function View() { result.current = hook(props); return null; } + const render = () => act(() => root.render(createElement(View))); + render(); + return { result, rerender(next: P) { props = next; render(); }, + unmount() { act(() => root.unmount()); dom.window.close(); } }; +} +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const events = vi.hoisted(() => new Map void>()); +vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ + ACPClientAPI: { onPlanUpdated: (callback: (event: any) => void) => { + events.set('plan', callback); + return () => events.delete('plan'); + } }, +})); +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: Object.fromEntries(['Completed', 'Cancelled', 'Failed'].map((name) => [ + `onDialogTurn${name}`, (callback: (event: any) => void) => { + events.set(name, callback); + return () => events.delete(name); + }, + ])), +})); + +import { initializeAcpPlanState } from '../services/acpPlanState'; +import { useAcpPlan } from './useAcpPlan'; + +let cleanup: (() => void) | undefined; +afterEach(() => { cleanup?.(); events.clear(); vi.unstubAllGlobals(); }); +const emitPlan = (status: string, turnId = 'turn-a') => { + events.get('plan')!({ sessionId: 'a', turnId, clientId: 'client', + entries: [{ content: 'step', priority: 'medium', status }] }); +}; + +describe('ACP plan session lifetime', () => { + it('retains plans across switching and receives progress with no mounted view', () => { + cleanup = initializeAcpPlanState(); + const view = renderHook(({ sessionId }) => useAcpPlan(sessionId), { + initialProps: { sessionId: 'a' as string | null }, + }); + act(() => emitPlan('pending')); + view.rerender({ sessionId: 'b' }); + expect(view.result.current.entries).toEqual([]); + view.rerender({ sessionId: 'a' }); + expect(view.result.current.entries[0].status).toBe('pending'); + view.unmount(); + act(() => emitPlan('in_progress')); + const restored = renderHook(() => useAcpPlan('a')); + expect(restored.result.current.entries[0].status).toBe('in_progress'); + restored.unmount(); + }); + + it.each(['Completed', 'Cancelled', 'Failed'])('clears a background turn on %s without clearing a newer turn', (event) => { + cleanup = initializeAcpPlanState(); + emitPlan('pending', 'new-turn'); + events.get(event)!({ sessionId: 'a', turnId: 'old-turn' }); + const view = renderHook(() => useAcpPlan('a')); + expect(view.result.current.entries).toHaveLength(1); + view.unmount(); + events.get(event)!({ sessionId: 'a', turnId: 'new-turn' }); + const restored = renderHook(() => useAcpPlan('a')); + expect(restored.result.current.entries).toEqual([]); + restored.unmount(); + }); +}); diff --git a/src/web-ui/src/flow_chat/hooks/useAcpPlan.ts b/src/web-ui/src/flow_chat/hooks/useAcpPlan.ts index 5fecf054cf..ca2e79f369 100644 --- a/src/web-ui/src/flow_chat/hooks/useAcpPlan.ts +++ b/src/web-ui/src/flow_chat/hooks/useAcpPlan.ts @@ -1,50 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; - import type { AcpPlanEntry } from '@/infrastructure/api/service-api/ACPClientAPI'; -import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; -import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; - -export function useAcpPlan(sessionId: string | null): { entries: AcpPlanEntry[] } { - const [entries, setEntries] = useState([]); - const latestTurnIdRef = useRef(null); - - useEffect(() => { - latestTurnIdRef.current = null; - setEntries([]); - }, [sessionId]); - - useEffect(() => { - if (!sessionId) return; +import { useAcpPlanStore } from '../services/acpPlanState'; - return ACPClientAPI.onPlanUpdated((event) => { - if (event.sessionId !== sessionId) return; - latestTurnIdRef.current = event.turnId; - setEntries(event.entries); - }); - }, [sessionId]); - - useEffect(() => { - if (!sessionId) return; - - const maybeClear = (event: { sessionId?: string; turnId?: string }) => { - if (event.sessionId !== sessionId) return; - const latestTurnId = latestTurnIdRef.current; - if (!latestTurnId || event.turnId === latestTurnId) { - latestTurnIdRef.current = null; - setEntries([]); - } - }; - - const unlistenCompleted = agentAPI.onDialogTurnCompleted(maybeClear); - const unlistenCancelled = agentAPI.onDialogTurnCancelled(maybeClear); - const unlistenFailed = agentAPI.onDialogTurnFailed(maybeClear); - - return () => { - unlistenCompleted(); - unlistenCancelled(); - unlistenFailed(); - }; - }, [sessionId]); +const EMPTY_ENTRIES: AcpPlanEntry[] = []; +export function useAcpPlan(sessionId: string | null): { entries: AcpPlanEntry[] } { + const entries = useAcpPlanStore((state) => + sessionId ? state.plans.get(sessionId)?.entries ?? EMPTY_ENTRIES : EMPTY_ENTRIES, + ); return { entries }; } diff --git a/src/web-ui/src/flow_chat/services/acpPlanState.ts b/src/web-ui/src/flow_chat/services/acpPlanState.ts new file mode 100644 index 0000000000..9a5ebc8a00 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/acpPlanState.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; +import { ACPClientAPI, type AcpPlanUpdatedEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; + +// Runtime presentation only: retain the latest report while its turn is running. +export const useAcpPlanStore = create<{ plans: Map }>(() => ({ + plans: new Map(), +})); + +export function initializeAcpPlanState(): () => void { + const clearTurn = (event: { sessionId?: string; turnId?: string }) => { + const current = event.sessionId && useAcpPlanStore.getState().plans.get(event.sessionId); + if (!current || current.turnId !== event.turnId) return; + useAcpPlanStore.setState(({ plans }) => { + const next = new Map(plans); + next.delete(current.sessionId); + return { plans: next }; + }); + }; + const unlisteners = [ + ACPClientAPI.onPlanUpdated((event) => { + useAcpPlanStore.setState(({ plans }) => ({ + plans: new Map(plans).set(event.sessionId, event), + })); + }), + agentAPI.onDialogTurnCompleted(clearTurn), + agentAPI.onDialogTurnCancelled(clearTurn), + agentAPI.onDialogTurnFailed(clearTurn), + ]; + return () => { + unlisteners.forEach((unlisten) => unlisten()); + useAcpPlanStore.setState({ plans: new Map() }); + }; +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 6aad1f6326..0c64298714 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -4,6 +4,7 @@ */ import { FlowChatStore, mergeModelRoundAttemptDiagnostics } from '../../store/FlowChatStore'; +import { initializeAcpPlanState } from '../acpPlanState'; import { isSessionTurnRetired } from '../../store/sessionMutationStore'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; @@ -892,6 +893,7 @@ export async function initializeEventListeners( }; await agenticEventListener.startListening(callbacks); + const cleanupAcpPlanState = initializeAcpPlanState(); return () => { unlistenProgress(); @@ -899,6 +901,7 @@ export async function initializeEventListeners( unlistenBackgroundCommandLifecycle(); unlistenMcpInteractionRequest(); unlistenAcpPermissionRequest(); + cleanupAcpPlanState(); agenticEventListener.stopListening(); }; }