diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 4c48794fb2..7439d800ea 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -7978,8 +7978,25 @@ def reference_handoff_would_drive_next_model_call( for index, message in enumerate(messages): if not is_compaction_summary_message(message): continue - if _handoff_carries_live_user_content(message): - # Embedded live ask — this row is not a sole-handoff driver. + previous = messages[index - 1] if index > 0 else None + merged_completed_assistant = ( + isinstance(message, dict) + and message.get("role") == "assistant" + and ContextCompressor.classify_summary_content( + message.get("content") + ) + == "merged" + and isinstance(previous, dict) + and previous.get("role") == "assistant" + and previous.get("finish_reason") == "stop" + ) + if ( + _handoff_carries_live_user_content(message) + and not merged_completed_assistant + ): + # Embedded live ask — this row is not a sole-handoff driver. A + # merged assistant carrier after a completed stop preserves the + # assistant's own prose/tool_calls, not a fresh user request. continue last_driving_handoff = index diff --git a/tests/agent/test_reference_handoff_active_turn.py b/tests/agent/test_reference_handoff_active_turn.py index 4e62b0f9b8..66b9fb06e3 100644 --- a/tests/agent/test_reference_handoff_active_turn.py +++ b/tests/agent/test_reference_handoff_active_turn.py @@ -14,8 +14,11 @@ from agent.context_compressor import ( COMPRESSED_SUMMARY_HAS_USER_TURN_KEY, COMPRESSED_SUMMARY_METADATA_KEY, COMPRESSION_CONTINUATION_USER_CONTENT, + ContextCompressor, HISTORICAL_TASK_HEADING, SUMMARY_PREFIX, + _MERGED_PRIOR_CONTEXT_HEADER, + _MERGED_SUMMARY_DELIMITER, _SUMMARY_END_MARKER, is_compaction_summary_message, is_user_originated_turn, @@ -39,6 +42,24 @@ def _standalone_handoff(task: str = "finish the already-done refactor") -> dict: } +def _merged_assistant_carrier() -> dict: + """Production merge-into-tail shape: assistant role/tool_calls survive.""" + return { + "role": "assistant", + "content": ( + f"{_MERGED_PRIOR_CONTEXT_HEADER}\n" + "Refactor complete.\n\n" + f"{_MERGED_SUMMARY_DELIMITER}\n" + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\n" + "User asked: 'finish the already-done refactor'\n\n" + f"{_SUMMARY_END_MARKER}" + ), + "tool_calls": [{"id": "stale-c1", "function": {"name": "terminal"}}], + COMPRESSED_SUMMARY_METADATA_KEY: True, + COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: True, + } + + class TestReferenceHandoffWouldDriveNextModelCall: def test_standalone_handoff_alone_drives(self): messages = [_standalone_handoff()] @@ -76,6 +97,70 @@ class TestReferenceHandoffWouldDriveNextModelCall: ] assert reference_handoff_would_drive_next_model_call(messages) is False + def test_merged_assistant_carrier_after_completed_stop_drives(self): + """Completed assistant prose/tool_calls on the carrier are not a user ask.""" + messages = [ + {"role": "user", "content": "please finish the refactor"}, + { + "role": "assistant", + "content": "Refactor complete.", + "finish_reason": "stop", + }, + _merged_assistant_carrier(), + ] + assert reference_handoff_would_drive_next_model_call(messages) is True + + def test_merged_assistant_carrier_without_completed_stop_stays_in_flight(self): + messages = [ + {"role": "user", "content": "please finish the refactor"}, + _merged_assistant_carrier(), + ] + assert reference_handoff_would_drive_next_model_call(messages) is False + + def test_standalone_assistant_handoff_after_stop_uses_existing_guard(self): + handoff = _standalone_handoff() + handoff["role"] = "assistant" + messages = [ + { + "role": "assistant", + "content": "Refactor complete.", + "finish_reason": "stop", + }, + handoff, + ] + assert ContextCompressor.classify_summary_content(handoff["content"]) == ( + "standalone" + ) + assert reference_handoff_would_drive_next_model_call(messages) is True + + def test_real_user_after_merged_assistant_carrier_does_not_drive(self): + messages = [ + { + "role": "assistant", + "content": "Refactor complete.", + "finish_reason": "stop", + }, + _merged_assistant_carrier(), + {"role": "user", "content": "start a different task"}, + ] + assert reference_handoff_would_drive_next_model_call(messages) is False + + def test_distinct_tool_call_after_merged_carrier_stays_in_flight(self): + messages = [ + { + "role": "assistant", + "content": "Refactor complete.", + "finish_reason": "stop", + }, + _merged_assistant_carrier(), + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "live-c2", "function": {"name": "terminal"}}], + }, + ] + assert reference_handoff_would_drive_next_model_call(messages) is False + def test_embedded_remainder_after_end_marker_does_not_drive(self): messages = [ {