diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index dc551ae528e..1c9f64af7b1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment happy-dom import { act, renderHook, waitFor } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { createRoot } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' @@ -377,29 +379,50 @@ describe('useStructuredAgentSessionOutbox', () => { }) }) - it('drops a session error on switch and does not resurrect it on return', async () => { - const redispatch = deferred>() - mocks.call - .mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale')) - .mockReturnValueOnce(redispatch.promise) - const { result, rerender } = renderHook( - ({ sessionId }: { sessionId: string }) => - useStructuredAgentSessionOutbox({ - sessionId, - target: LOCAL_TARGET, - fence: 1, - submissions: [] - }), - { initialProps: { sessionId: 'session-1' } } - ) + it('invalidates an old dispatch before it settles during a session switch', async () => { + const oldDispatch = deferred>() + const sessionTwoCommitted = deferred() + mocks.call.mockReturnValueOnce(oldDispatch.promise) + const controllerRef: { + current: ReturnType | null + } = { current: null } + function Probe({ sessionId }: { sessionId: string }): null { + controllerRef.current = useStructuredAgentSessionOutbox({ + sessionId, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + useLayoutEffect(() => { + if (sessionId === 'session-2') { + oldDispatch.resolve(refusedResult('agent_session_checkpoint_stale')) + sessionTwoCommitted.resolve() + } + }, [sessionId]) + return null + } - act(() => expect(result.current.send('hello')).toBe(true)) - await waitFor(() => expect(result.current.error).toBe('agent_session_checkpoint_stale')) + const container = document.createElement('div') + const root = createRoot(container) + const actEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT + try { + await act(async () => root.render()) + act(() => expect(controllerRef.current?.send('hello')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + const oldSettlementProcessed = oldDispatch.promise.then(() => undefined) - rerender({ sessionId: 'session-2' }) - expect(result.current.error).toBeNull() + globalThis.IS_REACT_ACT_ENVIRONMENT = false + root.render() + await sessionTwoCommitted.promise + globalThis.IS_REACT_ACT_ENVIRONMENT = actEnvironment + await act(async () => oldSettlementProcessed) - rerender({ sessionId: 'session-1' }) - expect(result.current.error).toBeNull() + expect(controllerRef.current?.error).toBeNull() + await act(async () => root.render()) + expect(controllerRef.current?.error).toBeNull() + } finally { + globalThis.IS_REACT_ACT_ENVIRONMENT = actEnvironment + await act(async () => root.unmount()) + } }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 9f7dde220fc..56ccefe3f76 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionMutationResult, @@ -93,10 +93,13 @@ export function useStructuredAgentSessionOutbox(args: { outboxRef.current = outbox }, [outbox]) - useEffect(() => { + useLayoutEffect(() => { dispatchGenerationRef.current += 1 dispatchingRef.current = false blockedIdRef.current = null + }, [fence, sessionId, target]) + + useEffect(() => { const sessionChanged = outboxSessionRef.current !== sessionId outboxSessionRef.current = sessionId const current = sessionChanged ? readOutbox(sessionId) : outboxRef.current