diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index d07a9b836e..1260ac7d47 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -341,15 +341,17 @@ def fork( but has its own identity and independent state going forward. """ fork_id = conversation_id or uuid.uuid4() - if agent is not None: - fork_agent = agent - else: - # Round-trip via JSON to produce a deep copy that avoids - # thread-lock pickling issues with model_copy(deep=True). - agent_cls = type(self.agent) - fork_agent = agent_cls.model_validate( - self.agent.model_dump(context={"expose_secrets": True}), - ) + # Always deep-copy the agent (supplied or source) so the fork owns + # its own object graph. Required because __init__ mutates + # agent.llm._prompt_cache_key in place (#2917): a shared/aliased + # agent would clobber the source conversation's cache key. + # Round-trip via JSON avoids thread-lock pickling issues with + # model_copy(deep=True). + source_agent = agent if agent is not None else self.agent + agent_cls = type(source_agent) + fork_agent = agent_cls.model_validate( + source_agent.model_dump(context={"expose_secrets": True}), + ) # Hold the state lock while reading mutable state from the source # conversation to avoid torn reads if run() is executing concurrently. diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py index 34d379ae5a..cb1f448ece 100644 --- a/tests/sdk/conversation/local/test_fork.py +++ b/tests/sdk/conversation/local/test_fork.py @@ -250,3 +250,29 @@ def test_fork_persisted_events_survive_reload(): resumed_ids = [e.id for e in resumed.state.events] assert evt_id_1 in resumed_ids assert evt_id_2 in resumed_ids + + +def test_fork_default_does_not_clobber_source_cache_key(): + """Default fork() must leave the source's prompt_cache_key intact (#2917).""" + with tempfile.TemporaryDirectory() as tmpdir: + src = Conversation(agent=_agent(), persistence_dir=tmpdir, workspace=tmpdir) + src_key_before = src.agent.llm._prompt_cache_key + + fork = src.fork() + + assert src.agent.llm._prompt_cache_key == src_key_before == str(src.id) + assert fork.agent.llm._prompt_cache_key == str(fork.id) + assert fork.agent.llm._prompt_cache_key != src.agent.llm._prompt_cache_key + + +def test_fork_with_aliased_agent_does_not_clobber_source_cache_key(): + """fork(agent=source.agent) must not repin the source LLM's cache key (#2917).""" + with tempfile.TemporaryDirectory() as tmpdir: + src = Conversation(agent=_agent(), persistence_dir=tmpdir, workspace=tmpdir) + src_key_before = src.agent.llm._prompt_cache_key + + fork = src.fork(agent=src.agent) + + assert src.agent.llm._prompt_cache_key == src_key_before == str(src.id) + assert fork.agent.llm._prompt_cache_key == str(fork.id) + assert fork.agent.llm is not src.agent.llm