tenseleyflow/loader / 1f55908

Browse files

Add direct tests for turn loop control

Authored by espadonne
SHA
1f559081d0037187f6315f31d49d62cdc273d9a0
Parents
455a0f5
Tree
e645d02

1 changed file

StatusFile+-
A tests/test_turn_loop.py 174 0
tests/test_turn_loop.pyadded
@@ -0,0 +1,174 @@
1
+"""Tests for main turn-loop orchestration."""
2
+
3
+from __future__ import annotations
4
+
5
+from pathlib import Path
6
+
7
+import pytest
8
+
9
+from loader.agent.loop import Agent, AgentConfig
10
+from loader.runtime.conversation import ConversationRuntime
11
+from loader.runtime.turn_iteration import TurnIterationAction, TurnIterationDecision
12
+from loader.runtime.turn_preamble import TurnPreludeDecision
13
+from tests.helpers.runtime_harness import ScriptedBackend
14
+
15
+
16
+def non_streaming_config() -> AgentConfig:
17
+    """Shared config for direct turn-loop tests."""
18
+
19
+    return AgentConfig(auto_context=False, stream=False, max_iterations=8)
20
+
21
+
22
+async def _prepare_runtime(
23
+    runtime: ConversationRuntime,
24
+    *,
25
+    task: str,
26
+) -> tuple:
27
+    events = []
28
+
29
+    async def capture(event) -> None:
30
+        events.append(event)
31
+
32
+    prepared = await runtime.turn_preparation.prepare(
33
+        task=task,
34
+        emit=capture,
35
+        requested_mode="execute",
36
+        original_task=None,
37
+        on_user_question=None,
38
+    )
39
+    return prepared, events, capture
40
+
41
+
42
+@pytest.mark.asyncio
43
+async def test_turn_loop_retries_after_preamble_continue(
44
+    temp_dir: Path,
45
+) -> None:
46
+    backend = ScriptedBackend()
47
+    agent = Agent(
48
+        backend=backend,
49
+        config=non_streaming_config(),
50
+        project_root=temp_dir,
51
+    )
52
+    runtime = ConversationRuntime(agent)
53
+    prepared, _, capture = await _prepare_runtime(
54
+        runtime,
55
+        task="Explain the runtime loop shape.",
56
+    )
57
+    prelude_calls: list[int] = []
58
+    iteration_calls: list[int] = []
59
+
60
+    async def fake_preamble(**kwargs):
61
+        prelude_calls.append(kwargs["iterations"])
62
+        kwargs["summary"].iterations = kwargs["iterations"]
63
+        return TurnPreludeDecision(should_continue=kwargs["iterations"] == 1)
64
+
65
+    async def fake_iteration(**kwargs):
66
+        iteration_calls.append(kwargs["iterations"])
67
+        return TurnIterationDecision(
68
+            action=TurnIterationAction.COMPLETE,
69
+            continuation_count=0,
70
+            empty_retry_count=0,
71
+            extracted_iterations=0,
72
+            consecutive_errors=0,
73
+        )
74
+
75
+    runtime.turn_preamble.prepare_iteration = fake_preamble
76
+    runtime.turn_iteration.run_iteration = fake_iteration
77
+
78
+    loop_exit = await runtime.turn_loop.run_loop(
79
+        task=prepared.task,
80
+        effective_task=prepared.effective_task,
81
+        original_task=None,
82
+        effective_max_tokens=prepared.effective_max_tokens,
83
+        dod=prepared.definition_of_done,
84
+        emit=capture,
85
+        summary=prepared.summary,
86
+        executor=prepared.executor,
87
+        rollback_plan=prepared.rollback_plan,
88
+        on_confirmation=None,
89
+        on_user_question=None,
90
+        emit_confirmation=runtime._emit_confirmation(capture),
91
+    )
92
+
93
+    assert prelude_calls == [1, 2]
94
+    assert iteration_calls == [2]
95
+    assert prepared.summary.iterations == 2
96
+    assert loop_exit.reason_code == "turn_complete"
97
+    assert loop_exit.reason_summary == "Finalizing completed turn"
98
+
99
+
100
+@pytest.mark.asyncio
101
+async def test_turn_loop_carries_iteration_state_between_attempts(
102
+    temp_dir: Path,
103
+) -> None:
104
+    backend = ScriptedBackend()
105
+    agent = Agent(
106
+        backend=backend,
107
+        config=non_streaming_config(),
108
+        project_root=temp_dir,
109
+    )
110
+    runtime = ConversationRuntime(agent)
111
+    prepared, _, capture = await _prepare_runtime(
112
+        runtime,
113
+        task="Inspect the loop bookkeeping.",
114
+    )
115
+    iteration_inputs: list[tuple[int, int, int, int, int, list[str]]] = []
116
+
117
+    async def fake_preamble(**kwargs):
118
+        kwargs["summary"].iterations = kwargs["iterations"]
119
+        return TurnPreludeDecision()
120
+
121
+    async def fake_iteration(**kwargs):
122
+        iteration_inputs.append(
123
+            (
124
+                kwargs["iterations"],
125
+                kwargs["continuation_count"],
126
+                kwargs["empty_retry_count"],
127
+                kwargs["extracted_iterations"],
128
+                kwargs["consecutive_errors"],
129
+                list(kwargs["actions_taken"]),
130
+            )
131
+        )
132
+        if len(iteration_inputs) == 1:
133
+            return TurnIterationDecision(
134
+                action=TurnIterationAction.CONTINUE,
135
+                continuation_count=1,
136
+                empty_retry_count=2,
137
+                extracted_iterations=1,
138
+                consecutive_errors=1,
139
+                new_actions_taken=["read: {'file_path': 'README.md'}"],
140
+            )
141
+        return TurnIterationDecision(
142
+            action=TurnIterationAction.FINALIZE,
143
+            continuation_count=1,
144
+            empty_retry_count=2,
145
+            extracted_iterations=1,
146
+            consecutive_errors=0,
147
+            finalize_reason_code="tool_batch_halted",
148
+            finalize_reason_summary="Finalizing after halted tool batch",
149
+        )
150
+
151
+    runtime.turn_preamble.prepare_iteration = fake_preamble
152
+    runtime.turn_iteration.run_iteration = fake_iteration
153
+
154
+    loop_exit = await runtime.turn_loop.run_loop(
155
+        task=prepared.task,
156
+        effective_task=prepared.effective_task,
157
+        original_task=None,
158
+        effective_max_tokens=prepared.effective_max_tokens,
159
+        dod=prepared.definition_of_done,
160
+        emit=capture,
161
+        summary=prepared.summary,
162
+        executor=prepared.executor,
163
+        rollback_plan=prepared.rollback_plan,
164
+        on_confirmation=None,
165
+        on_user_question=None,
166
+        emit_confirmation=runtime._emit_confirmation(capture),
167
+    )
168
+
169
+    assert iteration_inputs == [
170
+        (1, 0, 0, 0, 0, []),
171
+        (2, 1, 2, 1, 1, ["read: {'file_path': 'README.md'}"]),
172
+    ]
173
+    assert loop_exit.reason_code == "tool_batch_halted"
174
+    assert loop_exit.reason_summary == "Finalizing after halted tool batch"