Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion loopx/chat_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,18 @@ def start_turn(self, message: str, event_sink: EventSink) -> dict[str, Any]:
finally:
with self.lock:
self.current_process = None
raw_response = "".join(parts) or result_text
streamed_response = "".join(parts)
raw_response = streamed_response if streamed_response.strip() else result_text
if not raw_response.strip():
summary = "Claude Code completed without returning an answer."
raise CodexChatAgentError(
summary,
error_code="provider_empty_response",
gate=_host_tool_gate(
summary,
"Retry this session or select another healthy Agent endpoint.",
),
)
if not parts and result_text:
visible = display_filter.feed(result_text)
if visible:
Expand Down
87 changes: 87 additions & 0 deletions tests/test_chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path

import loopx.chat_agent as chat_agent
import loopx.chat_providers as chat_providers
import loopx.chat_runtime as chat_runtime
import pytest

Expand Down Expand Up @@ -35,6 +36,92 @@ def kill(self) -> None:
self.returncode = -1


class _FakeClaudeProcess:
def __init__(self, stdout: str) -> None:
self.stdout = io.StringIO(stdout)

def wait(self) -> int:
return 0


@pytest.mark.parametrize(
"stdout",
[
"",
"not-json\n[]\n",
json.dumps({"type": "result", "result": ""}) + "\n",
],
)
def test_claude_code_rejects_successful_process_without_a_response(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
stdout: str,
) -> None:
monkeypatch.setattr(
chat_providers.subprocess,
"Popen",
lambda *args, **kwargs: _FakeClaudeProcess(stdout),
)
adapter = chat_providers.ClaudeCodeAdapter(
claude_bin="claude",
work_dir=tmp_path,
session_id="session-fixture",
)
events: list[tuple[str, dict[str, object]]] = []

with pytest.raises(chat_agent.CodexChatAgentError) as caught:
adapter.start_turn(
"Reply briefly.",
lambda kind, payload: events.append((kind, payload)),
)

assert caught.value.error_code == "provider_empty_response"
assert not any(kind == "answer.final" for kind, _ in events)
assert adapter.resumed is False


@pytest.mark.parametrize(
"payload",
[
{"type": "result", "result": "Completed."},
{
"type": "stream_event",
"event": {
"type": "content_block_delta",
"delta": {"type": "text_delta", "text": "Completed."},
},
},
],
)
def test_claude_code_accepts_a_nonempty_response_event(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
payload: dict[str, object],
) -> None:
stdout = json.dumps({**payload, "session_id": "upstream-session"})
monkeypatch.setattr(
chat_providers.subprocess,
"Popen",
lambda *args, **kwargs: _FakeClaudeProcess(stdout + "\n"),
)
adapter = chat_providers.ClaudeCodeAdapter(
claude_bin="claude",
work_dir=tmp_path,
session_id="session-fixture",
)
events: list[tuple[str, dict[str, object]]] = []

response = adapter.start_turn(
"Reply briefly.",
lambda kind, payload: events.append((kind, payload)),
)

assert response["message"] == "Completed."
assert sum(kind == "answer.final" for kind, _ in events) == 1
assert adapter.session_id == "upstream-session"
assert adapter.resumed is True


def test_codex_chat_app_server_stdio_uses_utf8(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
Expand Down
Loading