tenseleyflow/loader / 4e907a5

Browse files

Persist first child handoffs after recovery

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
4e907a503608e52c8e1123a6cc1a7315ac1d843f
Parents
6bf8210
Tree
c193a13

3 changed files

StatusFile+-
M src/loader/runtime/tool_batches.py 31 1
M tests/test_tool_batches.py 4 4
M tests/test_turn_completion.py 90 0
src/loader/runtime/tool_batches.pymodified
@@ -1479,7 +1479,16 @@ class ToolBatchRunner:
14791479
             project_root=self.context.project_root,
14801480
         )
14811481
         session_messages = list(getattr(self.context.session, "messages", []) or [])
1482
-        if use_persistent_handoff and _recent_recovery_prompt(session_messages):
1482
+        if (
1483
+            use_persistent_handoff
1484
+            and _recent_recovery_prompt(session_messages)
1485
+            and not _should_preserve_first_child_handoff_after_recovery(
1486
+                tool_call=tool_call,
1487
+                resume_target=resume_target,
1488
+                dod=dod,
1489
+                project_root=self.context.project_root,
1490
+            )
1491
+        ):
14831492
             use_persistent_handoff = False
14841493
         queue_message = (
14851494
             self.context.queue_steering_message
@@ -2169,6 +2178,27 @@ def _should_use_persistent_missing_artifact_handoff(
21692178
     ) == 0
21702179
 
21712180
 
2181
+def _should_preserve_first_child_handoff_after_recovery(
2182
+    *,
2183
+    tool_call: ToolCall,
2184
+    resume_target: Path | None,
2185
+    dod: DefinitionOfDone,
2186
+    project_root: Path,
2187
+) -> bool:
2188
+    if resume_target is None or not resume_target.suffix or _is_summary_artifact_path(resume_target):
2189
+        return False
2190
+    raw_target = str(tool_call.arguments.get("file_path", "")).strip()
2191
+    if not raw_target:
2192
+        return False
2193
+    written_target = Path(raw_target).expanduser().resolve(strict=False)
2194
+    if not _is_summary_artifact_path(written_target):
2195
+        return False
2196
+    return _confirmed_substantive_file_artifact_count(
2197
+        dod,
2198
+        project_root=project_root,
2199
+    ) == 0
2200
+
2201
+
21722202
 def _is_summary_artifact_path(path: str | Path) -> bool:
21732203
     return Path(path).name.lower() in _SUMMARY_ARTIFACT_NAMES
21742204
 
tests/test_tool_batches.pymodified
@@ -2915,7 +2915,7 @@ async def test_tool_batch_runner_redirects_post_write_self_audit_to_next_missing
29152915
 
29162916
 
29172917
 @pytest.mark.asyncio
2918
-async def test_tool_batch_runner_softens_first_file_handoff_after_recovery_prompt(
2918
+async def test_tool_batch_runner_preserves_first_file_handoff_after_recovery_prompt(
29192919
     temp_dir: Path,
29202920
 ) -> None:
29212921
     async def assess_confidence(
@@ -3025,9 +3025,9 @@ async def test_tool_batch_runner_softens_first_file_handoff_after_recovery_promp
30253025
         consecutive_errors=0,
30263026
     )
30273027
 
3028
-    assert persistent_messages == []
3029
-    assert ephemeral_messages
3030
-    message = ephemeral_messages[-1]
3028
+    assert persistent_messages
3029
+    assert ephemeral_messages == []
3030
+    message = persistent_messages[-1]
30313031
     assert "Next step: create `01-introduction.html`." in message
30323032
     assert "Write a compact but real initial version of that file now" not in message
30333033
 
tests/test_turn_completion.pymodified
@@ -781,6 +781,96 @@ async def test_turn_completion_first_chapter_continuation_allows_compact_initial
781781
     assert "write a compact but real initial version of that file now" in agent.session.messages[-1].content.lower()
782782
 
783783
 
784
+@pytest.mark.asyncio
785
+async def test_turn_completion_interrupts_first_chapter_narration_from_declared_index_graph(
786
+    temp_dir: Path,
787
+) -> None:
788
+    backend = ScriptedBackend()
789
+    config = non_streaming_config()
790
+    config.reasoning.completion_check = False
791
+    agent = Agent(
792
+        backend=backend,
793
+        config=config,
794
+        project_root=temp_dir,
795
+    )
796
+    runtime = ConversationRuntime(agent)
797
+    events = []
798
+
799
+    async def capture(event) -> None:
800
+        events.append(event)
801
+
802
+    prepared = await runtime.turn_preparation.prepare(
803
+        task=(
804
+            "Create a multi-file nginx guide under ~/Loader/guides/nginx "
805
+            "with an index and chapter files."
806
+        ),
807
+        emit=capture,
808
+        requested_mode="execute",
809
+        original_task=None,
810
+        on_user_question=None,
811
+    )
812
+    await runtime.phase_tracker.enter(
813
+        TurnPhase.ASSISTANT,
814
+        capture,
815
+        detail="Requesting assistant response",
816
+        reason_code="request_assistant_response",
817
+    )
818
+
819
+    guide_root = temp_dir / "Loader" / "guides" / "nginx"
820
+    chapters_dir = guide_root / "chapters"
821
+    chapters_dir.mkdir(parents=True)
822
+    index_path = guide_root / "index.html"
823
+    index_path.write_text(
824
+        "\n".join(
825
+            [
826
+                "<!DOCTYPE html>",
827
+                '<a href="chapters/01-introduction.html">Chapter 1: Introduction to Nginx</a>',
828
+                '<a href="chapters/02-installation.html">Chapter 2: Installation and Setup</a>',
829
+                "",
830
+            ]
831
+        )
832
+    )
833
+
834
+    implementation_plan = temp_dir / "implementation.md"
835
+    implementation_plan.write_text(
836
+        "# Implementation Plan\n\n"
837
+        "## File Changes\n\n"
838
+        f"- `{index_path}`\n"
839
+        f"- `{chapters_dir}/`\n"
840
+    )
841
+
842
+    prepared.definition_of_done.implementation_plan = str(implementation_plan)
843
+    prepared.definition_of_done.touched_files.append(str(index_path))
844
+    prepared.definition_of_done.mutating_actions.append("write")
845
+    prepared.definition_of_done.pending_items.append(
846
+        "Develop the nginx guide content following the same structure and cadence as the fortran guide"
847
+    )
848
+
849
+    content = "Now I'll create the first chapter of the nginx guide."
850
+    decision = await runtime.turn_completion.handle_text_response(
851
+        content=content,
852
+        response_content=content,
853
+        task=prepared.task,
854
+        effective_task=prepared.effective_task,
855
+        iterations=1,
856
+        max_iterations=agent.config.max_iterations,
857
+        actions_taken=[],
858
+        continuation_count=0,
859
+        dod=prepared.definition_of_done,
860
+        emit=capture,
861
+        summary=prepared.summary,
862
+        executor=prepared.executor,
863
+        rollback_plan=prepared.rollback_plan,
864
+    )
865
+
866
+    assert decision.action == TurnCompletionAction.CONTINUE
867
+    assert decision.continuation_count == 1
868
+    assert prepared.summary.completion_decision_code == "in_progress_transition_continue"
869
+    assert agent.session.messages[-1].role.value == "user"
870
+    assert agent.session.messages[-1].content.startswith("[CONTINUE CURRENT STEP]")
871
+    assert "01-introduction.html" in agent.session.messages[-1].content
872
+
873
+
784874
 @pytest.mark.asyncio
785875
 async def test_turn_completion_handles_fake_tool_narration_without_reroute(
786876
     temp_dir: Path,