diff --git a/pr_split/logs.py b/pr_split/logs.py index a338046..d4e5552 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -103,6 +103,7 @@ " the diff will be sent to that host" ) LLM_OUTPUT_INCOMPLETE = "LLM output incomplete (status: {status}, reason: {reason})" +LLM_UNEXPECTED_STOP = "LLM stopped with stop_reason={stop_reason} (keys in tool input: {keys})" CHUNK_RETRY = "Chunk {index}/{total} failed (attempt {attempt}), retrying: {error}" INVALID_HUNK_INDEX = ( "Group '{group}': invalid hunk index {index} for {file} (max: {max}), skipping" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 892a8e8..c8751b0 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -181,9 +181,12 @@ def _call_anthropic(system: str, user: str, *, settings: Settings) -> RawToolOut if isinstance(block, BetaToolUseBlock) and isinstance(block.input, dict): keys = list(block.input.keys()) break - logger.warning(logs.LLM_OUTPUT_TRUNCATED.format(stop_reason=stop_reason, keys=keys)) if stop_reason == "max_tokens": + logger.warning(logs.LLM_OUTPUT_TRUNCATED.format(stop_reason=stop_reason, keys=keys)) raise LLMError(ErrorMsg.LLM_OUTPUT_TRUNCATED(detail=f"stop_reason={stop_reason}")) + # Any other stop reason is not a truncation; if a tool block is + # present the plan is complete, otherwise the loop below reports it. + logger.debug(logs.LLM_UNEXPECTED_STOP.format(stop_reason=stop_reason, keys=keys)) for block in response.content: if isinstance(block, BetaToolUseBlock) and block.name == SPLIT_TOOL_NAME: return RawToolOutput(groups=_extract_raw_output(block.input)) diff --git a/tests/test_client.py b/tests/test_client.py index 9d3ca5a..4aa3bf3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1337,6 +1337,25 @@ def test_failed_status_falls_back_to_error_code(self, mock_cls: MagicMock) -> No _call_openai("sys", "usr", settings=_make_settings(Provider.OPENAI)) +class TestTruncationWarningScope: + @patch("pr_split.planner.client.logger") + @patch("pr_split.planner.client.anthropic.Anthropic") + def test_end_turn_with_tool_block_does_not_warn_truncated( + self, mock_cls: MagicMock, mock_logger: MagicMock + ) -> None: + from anthropic.types.beta import BetaToolUseBlock + + block = BetaToolUseBlock( + id="tu_1", type="tool_use", name=SPLIT_TOOL_NAME, input={"groups": _SAMPLE_RAW_GROUPS} + ) + mock_cls.return_value.beta.messages.create.return_value = SimpleNamespace( + stop_reason="end_turn", content=[block] + ) + result = _call_anthropic("sys", "usr", settings=_make_settings(Provider.ANTHROPIC)) + assert result["groups"] == _SAMPLE_RAW_GROUPS + assert not any("truncated" in str(c.args[0]) for c in mock_logger.warning.call_args_list) + + class TestMergeChunkGroupsSameFile: def test_same_file_in_later_chunk_is_merged_into_one_assignment(self) -> None: first = Group(