claw-code/tests/test_run_turn_loop_continua...

162 lines
6.2 KiB
Python

"""Tests for run_turn_loop continuation contract (ROADMAP #163).
The deprecated ``f'{prompt} [turn N]'`` suffix injection is gone. Verifies:
- No ``[turn N]`` string ever lands in a submitted prompt
- Default (``continuation_prompt=None``) stops the loop after turn 0
- Explicit ``continuation_prompt`` is submitted verbatim on subsequent turns
- The first turn always gets the original prompt, not the continuation
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.models import UsageSummary # noqa: E402
from src.query_engine import TurnResult # noqa: E402
from src.runtime import PortRuntime # noqa: E402
def _completed_result(prompt: str) -> TurnResult:
return TurnResult(
prompt=prompt,
output='ok',
matched_commands=(),
matched_tools=(),
permission_denials=(),
usage=UsageSummary(),
stop_reason='completed',
)
class TestNoTurnSuffixInjection:
"""Core acceptance: no prompt submitted to the engine ever contains '[turn N]'."""
def test_default_path_submits_original_prompt_only(self) -> None:
runtime = PortRuntime()
submitted: list[str] = []
def _capture(prompt, commands, tools, denials):
submitted.append(prompt)
return _completed_result(prompt)
with patch('src.runtime.QueryEnginePort.from_workspace') as mock_factory:
engine = mock_factory.return_value
engine.submit_message.side_effect = _capture
runtime.run_turn_loop('investigate this bug', max_turns=3)
# Without continuation_prompt, only turn 0 should run
assert submitted == ['investigate this bug']
# And no '[turn N]' suffix anywhere
for p in submitted:
assert '[turn' not in p, f'found [turn suffix in submitted prompt: {p!r}'
def test_with_continuation_prompt_no_turn_suffix(self) -> None:
runtime = PortRuntime()
submitted: list[str] = []
def _capture(prompt, commands, tools, denials):
submitted.append(prompt)
return _completed_result(prompt)
with patch('src.runtime.QueryEnginePort.from_workspace') as mock_factory:
engine = mock_factory.return_value
engine.submit_message.side_effect = _capture
runtime.run_turn_loop(
'investigate this bug',
max_turns=3,
continuation_prompt='Continue.',
)
# Turn 0 = original, turns 1-2 = continuation, verbatim
assert submitted == ['investigate this bug', 'Continue.', 'Continue.']
# No harness-injected suffix anywhere
for p in submitted:
assert '[turn' not in p
assert not p.endswith(']')
class TestContinuationDefaultStopsAfterTurnZero:
def test_default_continuation_returns_one_result(self) -> None:
runtime = PortRuntime()
with patch('src.runtime.QueryEnginePort.from_workspace') as mock_factory:
engine = mock_factory.return_value
engine.submit_message.side_effect = lambda p, *_: _completed_result(p)
results = runtime.run_turn_loop('x', max_turns=5)
assert len(results) == 1
assert results[0].prompt == 'x'
def test_default_continuation_does_not_call_engine_twice(self) -> None:
runtime = PortRuntime()
with patch('src.runtime.QueryEnginePort.from_workspace') as mock_factory:
engine = mock_factory.return_value
engine.submit_message.side_effect = lambda p, *_: _completed_result(p)
runtime.run_turn_loop('x', max_turns=10)
# Exactly one submit_message call despite max_turns=10
assert engine.submit_message.call_count == 1
class TestExplicitContinuationBehaviour:
def test_first_turn_always_uses_original_prompt(self) -> None:
runtime = PortRuntime()
captured: list[str] = []
def _capture(prompt, *_):
captured.append(prompt)
return _completed_result(prompt)
with patch('src.runtime.QueryEnginePort.from_workspace') as mock_factory:
engine = mock_factory.return_value
engine.submit_message.side_effect = _capture
runtime.run_turn_loop(
'original task', max_turns=2, continuation_prompt='keep going'
)
assert captured[0] == 'original task'
assert captured[1] == 'keep going'
def test_continuation_respects_max_turns(self) -> None:
runtime = PortRuntime()
with patch('src.runtime.QueryEnginePort.from_workspace') as mock_factory:
engine = mock_factory.return_value
engine.submit_message.side_effect = lambda p, *_: _completed_result(p)
runtime.run_turn_loop('x', max_turns=3, continuation_prompt='go')
assert engine.submit_message.call_count == 3
class TestCLIContinuationFlag:
def test_cli_default_runs_one_turn(self) -> None:
"""Without --continuation-prompt, CLI should emit exactly '## Turn 1'."""
result = subprocess.run(
[sys.executable, '-m', 'src.main', 'turn-loop', 'review MCP tool',
'--max-turns', '3', '--structured-output'],
check=True, capture_output=True, text=True,
)
assert '## Turn 1' in result.stdout
assert '## Turn 2' not in result.stdout
assert '[turn' not in result.stdout
def test_cli_with_continuation_runs_multiple_turns(self) -> None:
"""With --continuation-prompt, CLI should run up to max_turns."""
result = subprocess.run(
[sys.executable, '-m', 'src.main', 'turn-loop', 'review MCP tool',
'--max-turns', '2', '--structured-output',
'--continuation-prompt', 'continue'],
check=True, capture_output=True, text=True,
)
assert '## Turn 1' in result.stdout
assert '## Turn 2' in result.stdout
# The continuation text is visible (it's submitted as the turn prompt)
# but no harness-injected [turn N] suffix
assert '[turn' not in result.stdout