from __future__ import annotations
import json
from typing import Any
import httpx
import pytest
from witty_agent_server.infra.clients.opencode_client import (
OpenCodeClient,
OpenCodeClientError,
)
def _client_with_transport(
handler, *, serve_port: int = 4096
) -> OpenCodeClient:
"""构造一个 OpenCodeClient,并把长持有 httpx.Client 直接塞进去。
`OpenCodeClient.http_client()` 现在返回长持有实例,因此测试不再 monkeypatch
方法,而是直接构造带 MockTransport 的 httpx.Client 注入到 `_http_client`。
"""
client = OpenCodeClient(serve_port=serve_port, username="u", password="p")
client._http_client = httpx.Client(
base_url=client.server_url,
auth=("u", "p"),
transport=httpx.MockTransport(handler),
timeout=30.0,
)
return client
def test_list_agents_returns_default_and_agents() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/agent"
return httpx.Response(
200,
json=[{"id": "main", "default": True}, {"id": "aux"}],
)
client = _client_with_transport(handler)
result = client.list_agents()
assert result["defaultId"] == "main"
assert [a["id"] for a in result["agents"]] == ["main", "aux"]
def test_list_agents_empty_list_defaults_main() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[])
client = _client_with_transport(handler)
assert client.list_agents() == {"defaultId": "main", "agents": []}
def test_list_sessions_ignores_agent_id() -> None:
received: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
received.append(request.url.path)
return httpx.Response(200, json=[{"id": "s1"}])
client = _client_with_transport(handler)
result = client.list_sessions(agent_id="ignored")
assert result == {"sessions": [{"id": "s1"}]}
assert received == ["/session"]
def test_create_session_stores_session_id() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/session"
body = request.read().decode()
assert '"title":"my-key"' in body.replace(" ", "")
return httpx.Response(200, json={"id": "opencode-sess-1"})
client = _client_with_transport(handler)
client.create_session(session_key="my-key")
assert client.resolve_session_id("my-key") == "opencode-sess-1"
def test_delete_session_uses_mapped_id() -> None:
deleted_paths: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(200, json={"id": "real-id"})
deleted_paths.append(request.url.path)
return httpx.Response(204)
client = _client_with_transport(handler)
client.create_session(session_key="k")
client.delete_session(session_key="k")
assert deleted_paths == ["/session/real-id"]
assert client.resolve_session_id("k") is None
@pytest.mark.parametrize(
"op",
["delete_session", "abort_session"],
)
def test_session_op_without_mapping_raises_409(op: str) -> None:
"""_session_map 未命中时(无预创建 mapping / 历史 session)抛 409。"""
client = _client_with_transport(lambda r: httpx.Response(204))
with pytest.raises(OpenCodeClientError) as exc:
getattr(client, op)(session_key="raw-key")
assert exc.value.status == 409
assert "raw-key" in exc.value.message
def test_abort_session_uses_mapped_id() -> None:
aborted_paths: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST" and request.url.path == "/session":
return httpx.Response(200, json={"id": "abort-real-id"})
aborted_paths.append(request.url.path)
return httpx.Response(204)
client = _client_with_transport(handler)
client.create_session(session_key="k")
client.abort_session(session_key="k")
assert aborted_paths == ["/session/abort-real-id/abort"]
def test_delete_session_falls_back_to_list_lookup_by_title() -> None:
"""_session_map miss 时按 title 在 /session 列表里找回 id,再 DELETE。"""
deleted_paths: list[str] = []
list_seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/session":
list_seen.append(request.url.path)
return httpx.Response(
200,
json=[{"id": "oc-123", "title": "recovered-key"}],
)
if request.method == "DELETE":
deleted_paths.append(request.url.path)
return httpx.Response(204)
client = _client_with_transport(handler)
client.delete_session(session_key="recovered-key")
assert list_seen == ["/session"]
assert deleted_paths == ["/session/oc-123"]
assert client.resolve_session_id("recovered-key") is None
def test_delete_session_list_without_title_falls_back_to_409() -> None:
"""list 响应不含 title 字段(schema 不一致)时回退到 409 而非误删。"""
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/session":
return httpx.Response(200, json=[{"id": "x"}])
return httpx.Response(204)
client = _client_with_transport(handler)
with pytest.raises(OpenCodeClientError) as exc:
client.delete_session(session_key="raw-key")
assert exc.value.status == 409
assert "raw-key" in exc.value.message
def test_delete_session_list_empty_falls_back_to_409() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/session":
return httpx.Response(200, json=[])
return httpx.Response(204)
client = _client_with_transport(handler)
with pytest.raises(OpenCodeClientError) as exc:
client.delete_session(session_key="raw-key")
assert exc.value.status == 409
def test_abort_session_falls_back_to_list_lookup_by_title() -> None:
aborted_paths: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/session":
return httpx.Response(200, json=[{"id": "oc-456", "title": "k-abort"}])
if request.method == "POST" and request.url.path.endswith("/abort"):
aborted_paths.append(request.url.path)
return httpx.Response(204)
client = _client_with_transport(handler)
client.abort_session(session_key="k-abort")
assert aborted_paths == ["/session/oc-456/abort"]
assert client.resolve_session_id("k-abort") == "oc-456"
def test_delete_session_uses_mapped_id_preferentially() -> None:
"""mapping 已存在时不触发 /session 列表查询,优先走内存映射。"""
list_invoked = False
def handler(request: httpx.Request) -> httpx.Response:
nonlocal list_invoked
if request.method == "POST" and request.url.path == "/session":
return httpx.Response(200, json={"id": "mapped-id"})
if request.method == "GET" and request.url.path == "/session":
list_invoked = True
return httpx.Response(200, json=[])
if request.method == "DELETE":
return httpx.Response(204)
return httpx.Response(204)
client = _client_with_transport(handler)
client.create_session(session_key="k")
client.delete_session(session_key="k")
assert not list_invoked
assert client.resolve_session_id("k") is None
def test_stream_client_returns_new_instance_each_call() -> None:
client = OpenCodeClient(serve_port=4096, username="u", password="p")
c1 = client.stream_client()
c2 = client.stream_client()
assert c1 is not c2
assert not c1.is_closed
assert not c2.is_closed
c1.close()
c2.close()
client.close()
def test_stream_client_does_not_affect_rpc_client() -> None:
"""stream_client() 不缓存、不替换 RPC 长持有 client。"""
client = OpenCodeClient(serve_port=4096, username="u", password="p")
rpc1 = client.http_client()
sc = client.stream_client()
sc.close()
rpc2 = client.http_client()
assert rpc1 is rpc2
assert not rpc2.is_closed
client.close()
def test_stream_client_close_safety() -> None:
"""stream_client() 用 with 关闭后,RPC 调用仍可继续(验证独立生命周期)。"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"id": "main"}])
client = OpenCodeClient(serve_port=4096, username="u", password="p")
client._http_client = httpx.Client(
base_url=client.server_url,
auth=("u", "p"),
transport=httpx.MockTransport(handler),
timeout=30.0,
)
with client.stream_client() as sc:
assert sc is not client.http_client()
result = client.list_agents()
assert result["defaultId"] == "main"
client.close()
def test_http_error_raises_client_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="boom")
client = _client_with_transport(handler)
with pytest.raises(OpenCodeClientError) as exc:
client.list_agents()
assert exc.value.status == 500
def test_default_constructor_uses_static_defaults() -> None:
client = OpenCodeClient()
assert client.server_url == "http://127.0.0.1:4096"
def test_constructor_args_override_defaults() -> None:
client = OpenCodeClient(serve_port=1234, username="u", password="p")
assert client.server_url == "http://127.0.0.1:1234"
def test_http_client_returns_singleton_across_calls() -> None:
client = OpenCodeClient()
c1 = client.http_client()
c2 = client.http_client()
assert c1 is c2
assert not c1.is_closed
client.close()
def test_http_client_rebuilds_after_close() -> None:
client = OpenCodeClient()
c1 = client.http_client()
client.close()
c2 = client.http_client()
assert c1 is not c2
assert c1.is_closed
assert not c2.is_closed
client.close()
def test_update_config_with_real_change_detaches_old_client() -> None:
"""update_config 真实变更后 detach 旧 client(置 None,不主动 close),
下一次 http_client() 用新参数重建。详见 opencode_client.py docstring。
"""
client = OpenCodeClient(serve_port=4096)
c1 = client.http_client()
client.update_config(serve_port=5050)
assert not c1.is_closed
assert client.server_url == "http://127.0.0.1:5050"
c2 = client.http_client()
assert c2 is not c1
client.close()
def test_update_config_with_no_change_keeps_client() -> None:
client = OpenCodeClient(serve_port=4096)
c1 = client.http_client()
client.update_config(serve_port=4096)
assert not c1.is_closed
c2 = client.http_client()
assert c2 is c1
client.close()
def test_update_config_username_password_timeout_rebuild() -> None:
client = OpenCodeClient(username="u", password="p", timeout=5.0)
c1 = client.http_client()
client.update_config(username="u2", password="p2", timeout=10.0)
assert not c1.is_closed
assert client.username == "u2"
assert client.password == "p2"
c2 = client.http_client()
assert c2 is not c1
client.close()
def test_close_is_idempotent() -> None:
client = OpenCodeClient()
client.http_client()
client.close()
client.close()
assert client._http_client is None
def test_answer_question_posts_to_reply_endpoint() -> None:
captured_urls: list[str] = []
captured_body: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_urls.append(request.url.path)
captured_body.update(json.loads(request.read()))
return httpx.Response(200, json={"status": "ok"})
client = _client_with_transport(handler)
result = client.answer_question(request_id="que_abc", answers=[["yes"], ["no"]])
assert result is True
assert captured_urls == ["/question/que_abc/reply"]
assert captured_body == {"answers": [["yes"], ["no"]]}
def test_reject_question_posts_to_reject_endpoint() -> None:
captured_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_urls.append(request.url.path)
return httpx.Response(200, json={"status": "ok"})
client = _client_with_transport(handler)
result = client.reject_question(request_id="que_xyz")
assert result is True
assert captured_urls == ["/question/que_xyz/reject"]
def test_answer_question_raises_on_http_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="internal error")
client = _client_with_transport(handler)
with pytest.raises(OpenCodeClientError) as exc:
client.answer_question(request_id="q1", answers=[["x"]])
assert exc.value.status == 500
def test_reject_question_raises_on_http_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(502, text="bad gateway")
client = _client_with_transport(handler)
with pytest.raises(OpenCodeClientError) as exc:
client.reject_question(request_id="q1")
assert exc.value.status == 502