@@ -172,6 +172,65 @@ class PayloadWithModel(PluginPayload):
172172 assert result is not None
173173 assert result .nested .x == 99 # type: ignore[union-attr]
174174
175+ def test_copyonwritedict_args_empty_modification_preserved (self ):
176+ """Regression test for bug where CopyOnWriteDict equality caused
177+ apply_policy to drop valid empty args modification.
178+
179+ When a plugin receives args as CopyOnWriteDict with data and returns
180+ an empty dict, apply_policy should treat this as a valid modification.
181+ Previously, CopyOnWriteDict.__eq__ was not implemented, causing the
182+ comparison to use dict's default equality which compared the empty
183+ base storage, incorrectly returning True for CopyOnWriteDict({...}) == {}.
184+ """
185+ from cpex .framework .memory import CopyOnWriteDict
186+
187+ policy = HookPayloadPolicy (writable_fields = frozenset ({"args" }))
188+
189+ # Simulate plugin receiving payload with CopyOnWriteDict args
190+ original = SamplePayload (
191+ name = "test" ,
192+ args = CopyOnWriteDict ({
193+ "wxo_connection_id" : "" ,
194+ "wxo_auth" : "fake-token" ,
195+ "wxo_environment_id" : "draft" ,
196+ }),
197+ secret = "s" ,
198+ )
199+
200+ # Plugin strips all args, returning empty dict
201+ modified = SamplePayload (name = "test" , args = {}, secret = "s" )
202+
203+ result = apply_policy (original , modified , policy )
204+
205+ # The modification should be preserved, not dropped
206+ assert result is not None , "apply_policy should not return None when args changed from {...} to {}"
207+ assert result .args == {} # type: ignore[union-attr]
208+ assert result .name == "test" # type: ignore[union-attr]
209+ assert result .secret == "s" # type: ignore[union-attr]
210+
211+ def test_copyonwritedict_args_partial_modification_preserved (self ):
212+ """Test that partial arg removal is also preserved correctly."""
213+ from cpex .framework .memory import CopyOnWriteDict
214+
215+ policy = HookPayloadPolicy (writable_fields = frozenset ({"args" }))
216+
217+ original = SamplePayload (
218+ name = "test" ,
219+ args = CopyOnWriteDict ({
220+ "wxo_auth" : "token" ,
221+ "real_arg" : "value" ,
222+ }),
223+ secret = "s" ,
224+ )
225+
226+ # Plugin removes only wxo_auth, keeping real_arg
227+ modified = SamplePayload (name = "test" , args = {"real_arg" : "value" }, secret = "s" )
228+
229+ result = apply_policy (original , modified , policy )
230+
231+ assert result is not None
232+ assert result .args == {"real_arg" : "value" } # type: ignore[union-attr]
233+
175234
176235class TestPluginPayloadFrozen :
177236 """Tests for frozen PluginPayload base class."""
@@ -752,6 +811,61 @@ async def tool_pre_invoke(self, payload, context):
752811 assert result .modified_payload .secret == "safe" # Policy filtered this out
753812
754813
814+ @pytest .mark .asyncio
815+ async def test_tool_pre_invoke_empty_args_modification_preserved_through_executor (self ):
816+ """Regression test for the tool_pre_invoke executor path.
817+
818+ A plugin receives CoW-wrapped args containing only specific fields,
819+ strips them all, and returns a payload with args={}. The executor should
820+ preserve that empty args modification instead of dropping it as
821+ "unchanged".
822+ """
823+ from cpex .framework .base import HookRef , Plugin , PluginRef
824+ from cpex .framework .hooks .policies import HookPayloadPolicy
825+ from cpex .framework .hooks .tools import ToolPreInvokePayload
826+ from cpex .framework .manager import PluginExecutor
827+ from cpex .framework .memory import CopyOnWriteDict
828+ from cpex .framework .models import GlobalContext , PluginConfig , PluginResult
829+
830+ seen_arg_types = []
831+
832+ class StripWxoArgsPlugin (Plugin ):
833+ async def tool_pre_invoke (self , payload , context ):
834+ seen_arg_types .append (type (payload .args ))
835+ cleaned_args = {k : v for k , v in payload .args .items () if not k .startswith ("wxo_" )}
836+ modified = payload .model_copy (update = {"args" : cleaned_args })
837+ return PluginResult (continue_processing = True , modified_payload = modified )
838+
839+ policies = {
840+ "tool_pre_invoke" : HookPayloadPolicy (writable_fields = frozenset ({"args" })),
841+ }
842+ executor = PluginExecutor (hook_policies = policies )
843+
844+ config = PluginConfig (name = "stripper" , kind = "test.Plugin" , version = "1.0" , hooks = ["tool_pre_invoke" ])
845+ plugin = StripWxoArgsPlugin (config )
846+ hook_ref = HookRef ("tool_pre_invoke" , PluginRef (plugin ))
847+
848+ payload = ToolPreInvokePayload (
849+ name = "list_all_secrets" ,
850+ args = {
851+ "wxo_connection_id" : "" ,
852+ "wxo_auth" : "fake-token" ,
853+ "wxo_environment_id" : "draft" ,
854+ },
855+ )
856+ global_ctx = GlobalContext (request_id = "tool-pre-empty-args" )
857+
858+ result , _ = await executor .execute ([hook_ref ], payload , global_ctx , hook_type = "tool_pre_invoke" )
859+
860+ assert seen_arg_types == [CopyOnWriteDict ]
861+ assert result .modified_payload is not None
862+ assert result .modified_payload == ToolPreInvokePayload (name = "list_all_secrets" , args = {})
863+ assert payload .args == {
864+ "wxo_connection_id" : "" ,
865+ "wxo_auth" : "fake-token" ,
866+ "wxo_environment_id" : "draft" ,
867+ }
868+
755869class TestMultiPluginDictChain :
756870 """Tests for multi-plugin chains where an earlier plugin returns a dict payload."""
757871
0 commit comments