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();
};
}