From 5a91124bc4589d2edf7d399792aaabfa7af622ae Mon Sep 17 00:00:00 2001 From: kina0630 Date: Thu, 14 May 2026 21:19:09 +0800 Subject: [PATCH] fix: surface setup phase failures --- .../src/coding_agent/ui/http_server.py | 36 +++++++++++-- coding-agent/tests/cli/test_remote_client.py | 51 +++++++++++++++++++ coding-agent/tests/ui/test_http_server.py | 45 ++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/coding-agent/src/coding_agent/ui/http_server.py b/coding-agent/src/coding_agent/ui/http_server.py index 60293a6cc..35b0a5517 100644 --- a/coding-agent/src/coding_agent/ui/http_server.py +++ b/coding-agent/src/coding_agent/ui/http_server.py @@ -860,6 +860,30 @@ def _session_origin_from_request( return origin +def _setup_phase_exception_detail(exc: BaseException) -> str | None: + notes = [ + note + for note in getattr(exc, "__notes__", ()) + if isinstance(note, str) and note.startswith("setup phase ") + ] + if not notes: + return None + + returncode = getattr(exc, "returncode", None) + if isinstance(returncode, int): + prefix = f"setup phase failed with exit code {returncode}" + else: + prefix = "setup phase failed" + return "\n".join([prefix, *notes]) + + +def _http_exception_detail(exc: BaseException) -> str: + setup_detail = _setup_phase_exception_detail(exc) + if setup_detail is not None: + return setup_detail + return str(exc) + + def _http_safe_tool_result_payload(msg: ToolResultDelta) -> dict[str, Any]: return { "session_id": msg.session_id, @@ -1300,10 +1324,16 @@ async def create_session( if isinstance(exc, HTTPException): raise exc if isinstance(exc, RuntimeError): - raise HTTPException(status_code=500, detail=str(exc)) from exc + raise HTTPException( + status_code=500, detail=_http_exception_detail(exc) + ) from exc if isinstance(exc, (ValueError, TypeError)): - raise HTTPException(status_code=400, detail=str(exc)) from exc - raise HTTPException(status_code=500, detail=str(exc)) from exc + raise HTTPException( + status_code=400, detail=_http_exception_detail(exc) + ) from exc + raise HTTPException( + status_code=500, detail=_http_exception_detail(exc) + ) from exc logger.info(f"Created session: {session_id}") return SessionResponse(session_id=session_id) diff --git a/coding-agent/tests/cli/test_remote_client.py b/coding-agent/tests/cli/test_remote_client.py index c23079cee..ebde1e6ff 100644 --- a/coding-agent/tests/cli/test_remote_client.py +++ b/coding-agent/tests/cli/test_remote_client.py @@ -3154,6 +3154,57 @@ def post( assert "connection refused" in result.output +def test_remote_repl_reports_setup_failure_without_docker_command( + tmp_path: Path, monkeypatch +) -> None: + config_path = tmp_path / "remotes.json" + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example"], + catch_exceptions=False, + ) + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + del kwargs + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def post( + self, path: str, json: dict[str, object] | None = None + ) -> httpx.Response: + del path, json + request = httpx.Request("POST", "http://agent.example/sessions") + return httpx.Response( + 500, + request=request, + json={ + "detail": ( + "setup phase failed with exit code 42\n" + "setup phase stderr:\nsetup failed intentionally\n" + ) + }, + ) + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + + result = runner.invoke( + main, + ["remote", "repl", "dev", "--empty-workspace", "--goal", "hello"], + ) + + assert result.exit_code != 0 + assert "setup phase failed with exit code 42" in result.output + assert "setup failed intentionally" in result.output + assert "docker run" not in result.output + + def test_stream_prompt_reports_non_200_sse_response(monkeypatch) -> None: class FakeResponse: status_code = 503 diff --git a/coding-agent/tests/ui/test_http_server.py b/coding-agent/tests/ui/test_http_server.py index 43b5e914c..d03db5421 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -1021,6 +1021,51 @@ async def test_create_session_rejects_workspace_provisioning_when_disabled( "cloud workspace provisioning requires cloud_workspace.enabled=true" ) + async def test_create_session_surfaces_redacted_setup_failure_detail( + self, client, monkeypatch, tmp_path + ): + monkeypatch.setattr( + "coding_agent.ui.http_server._load_cloud_workspace_config", + lambda: { + "enabled": True, + "provider": "docker", + "workspace_root": str(tmp_path), + "container_name_prefix": "agent-", + **_test_runtime_profile_config(), + }, + ) + + def fail_setup(config: dict[str, object], source: dict[str, object]): + del config, source + exc = subprocess.CalledProcessError( + returncode=42, + cmd=["docker", "run", "--name", "secret-container"], + ) + exc.add_note("setup phase stdout:\n[REDACTED]\n") + exc.add_note("setup phase stderr:\nsetup failed intentionally\n") + raise exc + + monkeypatch.setattr( + "coding_agent.ui.http_server.provision_cloud_binding_from_config", + fail_setup, + ) + + response = await client.post( + "/sessions", + json={"workspace_source": {"kind": "docker"}}, + ) + + assert response.status_code == 500 + detail = response.json()["detail"] + assert detail == ( + "setup phase failed with exit code 42\n" + "setup phase stdout:\n[REDACTED]\n" + "\n" + "setup phase stderr:\nsetup failed intentionally\n" + ) + assert "docker run" not in detail + assert "secret-container" not in detail + async def test_create_session_provisions_git_workspace_source( self, client, monkeypatch, tmp_path ):