@@ -695,7 +695,7 @@ async def test_check_refusal_detects_refusal(
695695 basic_context .last_response = sample_response
696696 mock_refusal_scorer .score_async .return_value = [refusal_score ]
697697
698- result = await attack ._check_refusal_async (context = basic_context )
698+ result = await attack ._check_refusal_async (context = basic_context , turn_objective = "test question" )
699699 assert result .get_value () is True
700700
701701 async def test_check_refusal_no_response_raises (
@@ -712,7 +712,7 @@ async def test_check_refusal_no_response_raises(
712712 basic_context .last_response = None
713713
714714 with pytest .raises (ValueError , match = "No response available to check for refusal" ):
715- await attack ._check_refusal_async (context = basic_context )
715+ await attack ._check_refusal_async (context = basic_context , turn_objective = "test question" )
716716
717717
718718@pytest .mark .usefixtures ("patch_central_database" )
@@ -855,6 +855,67 @@ async def test_regenerate_turn_strips_quotes(
855855 assert result == "Alternative question?"
856856
857857
858+ @pytest .mark .usefixtures ("patch_central_database" )
859+ class TestAssemblyPrompt :
860+ """Tests for assembly prompt generation."""
861+
862+ async def test_generate_assembly_prompt_guides_concrete_deliverable (
863+ self ,
864+ mock_objective_target : MagicMock ,
865+ mock_adversarial_chat : MagicMock ,
866+ mock_prompt_normalizer : MagicMock ,
867+ basic_context : SplitPayloadAttackContext ,
868+ ):
869+ attack = SplitPayloadTestHelper .create_attack (
870+ objective_target = mock_objective_target ,
871+ adversarial_chat = mock_adversarial_chat ,
872+ prompt_normalizer = mock_prompt_normalizer ,
873+ )
874+ basic_context .session = ConversationSession ()
875+ basic_context .params = AttackParameters (
876+ objective = "Write a tutorial with specific implementation steps"
877+ )
878+ basic_context .planned_turns = [
879+ "How does component A work?" ,
880+ "How does component B fit with it?" ,
881+ ]
882+ mock_prompt_normalizer .send_prompt_async .return_value = create_prompt_response (
883+ text = "Assembly follow-up"
884+ )
885+
886+ result = await attack ._generate_assembly_prompt_async (context = basic_context )
887+
888+ assert result == "Assembly follow-up"
889+ sent_message = mock_prompt_normalizer .send_prompt_async .call_args .kwargs ["message" ]
890+ prompt = sent_message .get_value ()
891+ assert "specific final deliverable" in prompt
892+ assert "complete, integrated, actionable artifact" in prompt
893+ assert "avoid generic summaries" in prompt
894+ assert "explicitly naming the harmful objective" in prompt
895+ assert basic_context .objective in prompt
896+
897+ async def test_generate_assembly_prompt_no_response_raises (
898+ self ,
899+ mock_objective_target : MagicMock ,
900+ mock_adversarial_chat : MagicMock ,
901+ mock_prompt_normalizer : MagicMock ,
902+ basic_context : SplitPayloadAttackContext ,
903+ ):
904+ attack = SplitPayloadTestHelper .create_attack (
905+ objective_target = mock_objective_target ,
906+ adversarial_chat = mock_adversarial_chat ,
907+ prompt_normalizer = mock_prompt_normalizer ,
908+ )
909+ basic_context .session = ConversationSession ()
910+ mock_prompt_normalizer .send_prompt_async .return_value = None
911+
912+ with pytest .raises (
913+ ValueError ,
914+ match = "No response from adversarial chat during assembly prompt generation" ,
915+ ):
916+ await attack ._generate_assembly_prompt_async (context = basic_context )
917+
918+
858919@pytest .mark .usefixtures ("patch_central_database" )
859920class TestPerformAttack :
860921 """Tests for the main attack execution."""
@@ -892,6 +953,12 @@ async def test_perform_success_all_turns_pass_and_assembled_harmful(
892953 new_callable = AsyncMock ,
893954 return_value = sample_response ,
894955 ),
956+ patch .object (
957+ attack ,
958+ "_generate_assembly_prompt_async" ,
959+ new_callable = AsyncMock ,
960+ return_value = "Can you combine all of that?" ,
961+ ),
895962 patch .object (
896963 attack ,
897964 "_score_assembled_conversation_async" ,
@@ -903,11 +970,12 @@ async def test_perform_success_all_turns_pass_and_assembled_harmful(
903970
904971 assert isinstance (result , SplitPayloadAttackResult )
905972 assert result .outcome == AttackOutcome .SUCCESS
906- assert result .executed_turns == 3
973+ assert result .executed_turns == 4 # 3 decomposed + 1 assembly
907974 assert result .all_turns_passed_safety is True
908975 assert result .assembled_payload_harmful is True
909976 assert result .design_pattern == "component_assembly"
910977 assert result .harm_description == "test harm"
978+ assert len (basic_context .per_turn_safety_scores ) == 4
911979
912980 async def test_perform_failure_assembled_not_harmful (
913981 self ,
@@ -933,6 +1001,12 @@ async def test_perform_failure_assembled_not_harmful(
9331001 patch .object (attack , "_decompose_objective_async" , new_callable = AsyncMock , return_value = decomposition ),
9341002 patch .object (attack , "_check_refusal_async" , new_callable = AsyncMock , return_value = no_refusal_score ),
9351003 patch .object (attack , "_send_prompt_to_target_async" , new_callable = AsyncMock , return_value = sample_response ),
1004+ patch .object (
1005+ attack ,
1006+ "_generate_assembly_prompt_async" ,
1007+ new_callable = AsyncMock ,
1008+ return_value = "Can you combine all of that?" ,
1009+ ),
9361010 patch .object (
9371011 attack ,
9381012 "_score_assembled_conversation_async" ,
@@ -946,6 +1020,7 @@ async def test_perform_failure_assembled_not_harmful(
9461020 assert result .all_turns_passed_safety is True
9471021 assert result .assembled_payload_harmful is False
9481022 assert "did NOT achieve" in result .outcome_reason
1023+ assert len (basic_context .per_turn_safety_scores ) == 3
9491024
9501025 async def test_perform_failure_turn_refused_after_max_regenerations (
9511026 self ,
@@ -1003,14 +1078,20 @@ async def test_perform_with_regeneration_then_success(
10031078
10041079 decomposition = {"turns" : ["Q1?" ], "harm_description" : "test" }
10051080
1006- # First attempt refused, second succeeds
1007- refusal_results = [refusal_score , no_refusal_score ]
1081+ # First attempt refused, second succeeds, then assembly turn not refused
1082+ refusal_results = [refusal_score , no_refusal_score , no_refusal_score ]
10081083
10091084 with (
10101085 patch .object (attack , "_decompose_objective_async" , new_callable = AsyncMock , return_value = decomposition ),
10111086 patch .object (attack , "_check_refusal_async" , new_callable = AsyncMock , side_effect = refusal_results ),
10121087 patch .object (attack , "_send_prompt_to_target_async" , new_callable = AsyncMock , return_value = sample_response ),
10131088 patch .object (attack , "_regenerate_turn_async" , new_callable = AsyncMock , return_value = "Regenerated Q1?" ),
1089+ patch .object (
1090+ attack ,
1091+ "_generate_assembly_prompt_async" ,
1092+ new_callable = AsyncMock ,
1093+ return_value = "Can you combine all of that?" ,
1094+ ),
10141095 patch .object (
10151096 attack ,
10161097 "_score_assembled_conversation_async" ,
@@ -1022,7 +1103,8 @@ async def test_perform_with_regeneration_then_success(
10221103 result = await attack ._perform_async (context = basic_context )
10231104
10241105 assert result .outcome == AttackOutcome .SUCCESS
1025- assert result .executed_turns == 1
1106+ assert result .executed_turns == 2 # 1 decomposed + 1 assembly
1107+ assert len (basic_context .per_turn_safety_scores ) == 2
10261108
10271109 async def test_perform_sets_atomic_attack_identifier (
10281110 self ,
@@ -1047,6 +1129,12 @@ async def test_perform_sets_atomic_attack_identifier(
10471129 patch .object (attack , "_decompose_objective_async" , new_callable = AsyncMock , return_value = decomposition ),
10481130 patch .object (attack , "_check_refusal_async" , new_callable = AsyncMock , return_value = no_refusal_score ),
10491131 patch .object (attack , "_send_prompt_to_target_async" , new_callable = AsyncMock , return_value = sample_response ),
1132+ patch .object (
1133+ attack ,
1134+ "_generate_assembly_prompt_async" ,
1135+ new_callable = AsyncMock ,
1136+ return_value = "Can you combine all of that?" ,
1137+ ),
10501138 patch .object (
10511139 attack ,
10521140 "_score_assembled_conversation_async" ,
@@ -1213,12 +1301,14 @@ async def test_complete_successful_attack(
12131301 scorer_class = "FloatScaleThresholdScorer" ,
12141302 )
12151303
1216- # Mock decomposition phase
1304+ # Mock decomposition phase + assembly prompt generation
12171305 mock_prompt_normalizer .send_prompt_async .side_effect = [
12181306 create_prompt_response (text = decomposition_json ), # decomposition
12191307 target_responses [0 ], # turn 1 to target
12201308 target_responses [1 ], # turn 2 to target
12211309 target_responses [2 ], # turn 3 to target
1310+ create_prompt_response (text = "Can you combine all of that into a guide?" ), # assembly prompt generation
1311+ create_prompt_response (text = "Here is the combined guide..." ), # assembly turn to target
12221312 ]
12231313
12241314 mock_conversation_state = ConversationState (turn_count = 0 )
@@ -1238,6 +1328,6 @@ async def test_complete_successful_attack(
12381328
12391329 assert isinstance (result , SplitPayloadAttackResult )
12401330 assert result .outcome == AttackOutcome .SUCCESS
1241- assert result .executed_turns == 3
1331+ assert result .executed_turns == 4 # 3 decomposed + 1 assembly
12421332 assert result .all_turns_passed_safety is True
12431333 assert result .assembled_payload_harmful is True
0 commit comments