@@ -2091,3 +2091,111 @@ def source_b():
20912091 # We assert that the FIRST trigger (for from_a) was processed FIRST!
20922092 assert interrupt_id_2_first == 'req_d_from_a'
20932093 assert interrupt_id_2_second == 'req_e_from_b'
2094+
2095+
2096+ class _BranchRouterNode (BaseNode ):
2097+ """Routes the first invocation and later invocations down different branches.
2098+
2099+ Stands in for the LlmAgent classification in the original report: the model
2100+ is incidental, what matters is that two invocations in one session take
2101+ different branches, so the first invocation ends on a node the second never
2102+ runs.
2103+ """
2104+
2105+ model_config = ConfigDict (arbitrary_types_allowed = True )
2106+ seen_invocations : list [str ] = Field (default_factory = list )
2107+
2108+ @override
2109+ async def _run_impl (
2110+ self , * , ctx : Context , node_input : Any
2111+ ) -> AsyncGenerator [Any , None ]:
2112+ self .seen_invocations .append (ctx .invocation_id )
2113+ distinct = list (dict .fromkeys (self .seen_invocations ))
2114+ route = 'DONE' if len (distinct ) == 1 else 'NEEDS_INPUT'
2115+ yield Event (output = node_input , route = route )
2116+
2117+
2118+ class _ClarifyNode (BaseNode ):
2119+ """Pauses for input on first execution, emits the answer once resumed."""
2120+
2121+ model_config = ConfigDict (arbitrary_types_allowed = True )
2122+ rerun_on_resume : bool = Field (default = True )
2123+
2124+ @override
2125+ async def _run_impl (
2126+ self , * , ctx : Context , node_input : Any
2127+ ) -> AsyncGenerator [Any , None ]:
2128+ interrupt_id = f'clarify:{ ctx .run_id } '
2129+ response = ctx .resume_inputs .get (interrupt_id )
2130+ if response is None :
2131+ yield RequestInput (interrupt_id = interrupt_id , message = 'Which city?' )
2132+ return
2133+ yield Event (output = f'resumed:{ response } ' )
2134+
2135+
2136+ @pytest .mark .asyncio
2137+ async def test_request_input_resume_after_earlier_invocation_completed (
2138+ request : pytest .FixtureRequest ,
2139+ ):
2140+ """A completed earlier invocation must not block a later HITL resume.
2141+
2142+ Regression test for #6497. The first invocation finishes on the `finish`
2143+ branch. The second invocation takes the `clarify` branch and pauses for
2144+ input. When the replay sequence was built from every event in the session,
2145+ the terminal `finish` event of the first invocation entered the sequence
2146+ and, being chronologically first, was the only key the sequence barrier
2147+ unblocked. `finish` never runs during the resume, so the pending node waited
2148+ out the barrier timeout and raised "Replay divergence detected".
2149+ """
2150+ prepare = _TestingNode (name = 'prepare_intent_text' , message = 'prepped' )
2151+ router = _BranchRouterNode (name = 'route' )
2152+ finish = _TestingNode (name = 'finish' , message = 'done' )
2153+ clarify = _ClarifyNode (name = 'clarify' )
2154+
2155+ agent = Workflow (
2156+ name = 'test_workflow_replay_across_invocations' ,
2157+ edges = [
2158+ Edge (from_node = START , to_node = prepare ),
2159+ Edge (from_node = prepare , to_node = router ),
2160+ Edge (from_node = router , to_node = finish , route = 'DONE' ),
2161+ Edge (from_node = router , to_node = clarify , route = 'NEEDS_INPUT' ),
2162+ ],
2163+ )
2164+ app = App (
2165+ name = request .function .__name__ ,
2166+ root_agent = agent ,
2167+ resumability_config = ResumabilityConfig (is_resumable = True ),
2168+ )
2169+ runner = testing_utils .InMemoryRunner (app = app )
2170+
2171+ # Invocation 1: runs to completion down the `finish` branch.
2172+ events1 = await runner .run_async (
2173+ testing_utils .get_user_content ('who are you' )
2174+ )
2175+ outputs1 = [e .output for e in events1 if e .output is not None ]
2176+ assert 'done' in outputs1
2177+
2178+ # Invocation 2, same session: takes the `clarify` branch and pauses.
2179+ events2 = await runner .run_async (
2180+ testing_utils .get_user_content ('weather please' )
2181+ )
2182+ request_input_event = workflow_testing_utils .find_function_call_event (
2183+ events2 , REQUEST_INPUT_FUNCTION_CALL_NAME
2184+ )
2185+ assert request_input_event is not None
2186+ interrupt_id = get_request_input_interrupt_ids (request_input_event )[0 ]
2187+ invocation_id = request_input_event .invocation_id
2188+
2189+ # Resuming must reach the pending node instead of timing out on `finish`.
2190+ events3 = await runner .run_async (
2191+ new_message = testing_utils .UserContent (
2192+ create_request_input_response (interrupt_id , {'result' : 'Berlin' })
2193+ ),
2194+ invocation_id = invocation_id ,
2195+ )
2196+
2197+ outputs3 = [e .output for e in events3 if e .output is not None ]
2198+ assert any (
2199+ isinstance (output , str ) and output .startswith ('resumed:' )
2200+ for output in outputs3
2201+ ), outputs3
0 commit comments