Skip to content

Commit 1b8ffdf

Browse files
authored
Merge pull request #8 from getlark/vd-20260315-1619
Add comprehensive integration tests and fix WebSocket cancellation hang
2 parents 0c487ee + 4ffd9f6 commit 1b8ffdf

20 files changed

Lines changed: 726 additions & 5 deletions

File tree

.github/workflows/test-runtimeuse-client-python.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@ jobs:
3232
- run: pip install -e ".[dev]" 2>/dev/null || pip install -e .
3333
working-directory: packages/runtimeuse-client-python
3434
- run: pip install pytest pytest-asyncio
35-
- run: pytest test/
35+
- run: pytest test/ -m "not sandbox and not llm"
3636
working-directory: packages/runtimeuse-client-python

packages/runtimeuse-client-python/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ packages = ["src/runtimeuse_client"]
3232

3333
[tool.pytest.ini_options]
3434
asyncio_mode = "auto"
35+
log_cli = true
36+
log_cli_level = "INFO"
3537
markers = [
3638
"e2e: end-to-end tests requiring a running runtimeuse server",
39+
"sandbox: sandbox provider integration tests (requires E2B_API_KEY)",
40+
"llm: real LLM integration tests (requires E2B_API_KEY + LLM API keys)",
3741
]

packages/runtimeuse-client-python/src/runtimeuse_client/transports/websocket_transport.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,7 @@ async def _queue_sender(
4343
) -> None:
4444
while True:
4545
message = await send_queue.get()
46-
await ws.send(json.dumps(message))
47-
send_queue.task_done()
46+
try:
47+
await ws.send(json.dumps(message))
48+
finally:
49+
send_queue.task_done()

packages/runtimeuse-client-python/test/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import asyncio
22
from typing import Any, AsyncGenerator
33

4+
import dotenv
45
import pytest
56

67
from src.runtimeuse_client import RuntimeUseClient, QueryOptions
78

9+
dotenv.load_dotenv()
10+
811

912
class FakeTransport:
1013
"""In-memory transport for testing.

packages/runtimeuse-client-python/test/e2e/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def _port_is_open(port: int) -> bool:
2626
return s.connect_ex(("127.0.0.1", port)) == 0
2727

2828

29-
@pytest.fixture(scope="session")
29+
@pytest.fixture
3030
def ws_url():
3131
"""Start a local runtimeuse server with the echo handler and yield its URL."""
3232
if not CLI_JS.exists():

packages/runtimeuse-client-python/test/e2e/test_e2e.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
AssistantMessageInterface,
1515
AgentRuntimeError,
1616
CancelledException,
17+
CommandInterface,
1718
)
1819

1920
pytestmark = [pytest.mark.e2e, pytest.mark.asyncio]
@@ -107,6 +108,149 @@ async def abort_on_first(msg: AssistantMessageInterface):
107108
)
108109

109110

111+
class TestPrePostCommands:
112+
async def test_pre_command_output_streamed(
113+
self, client: RuntimeUseClient, make_query_options
114+
):
115+
received: list[AssistantMessageInterface] = []
116+
117+
async def on_msg(msg: AssistantMessageInterface):
118+
received.append(msg)
119+
120+
result = await client.query(
121+
prompt="ECHO:hello",
122+
options=make_query_options(
123+
pre_agent_invocation_commands=[
124+
CommandInterface(command="echo pre-sentinel")
125+
],
126+
on_assistant_message=on_msg,
127+
),
128+
)
129+
130+
assert isinstance(result.data, TextResult)
131+
assert result.data.text == "hello"
132+
all_text = [block for msg in received for block in msg.text_blocks]
133+
assert any("pre-sentinel" in t for t in all_text)
134+
135+
async def test_post_command_output_streamed(
136+
self, client: RuntimeUseClient, make_query_options
137+
):
138+
received: list[AssistantMessageInterface] = []
139+
140+
async def on_msg(msg: AssistantMessageInterface):
141+
received.append(msg)
142+
143+
result = await client.query(
144+
prompt="ECHO:hello",
145+
options=make_query_options(
146+
post_agent_invocation_commands=[
147+
CommandInterface(command="echo post-sentinel")
148+
],
149+
on_assistant_message=on_msg,
150+
),
151+
)
152+
153+
assert isinstance(result.data, TextResult)
154+
assert result.data.text == "hello"
155+
all_text = [block for msg in received for block in msg.text_blocks]
156+
assert any("post-sentinel" in t for t in all_text)
157+
158+
async def test_pre_and_post_commands_both_run(
159+
self, client: RuntimeUseClient, make_query_options
160+
):
161+
received: list[AssistantMessageInterface] = []
162+
163+
async def on_msg(msg: AssistantMessageInterface):
164+
received.append(msg)
165+
166+
result = await client.query(
167+
prompt="ECHO:hello",
168+
options=make_query_options(
169+
pre_agent_invocation_commands=[
170+
CommandInterface(command="echo pre-sentinel")
171+
],
172+
post_agent_invocation_commands=[
173+
CommandInterface(command="echo post-sentinel")
174+
],
175+
on_assistant_message=on_msg,
176+
),
177+
)
178+
179+
assert isinstance(result.data, TextResult)
180+
assert result.data.text == "hello"
181+
all_text = [block for msg in received for block in msg.text_blocks]
182+
assert any("pre-sentinel" in t for t in all_text)
183+
assert any("post-sentinel" in t for t in all_text)
184+
185+
async def test_pre_command_with_cwd(
186+
self, client: RuntimeUseClient, make_query_options
187+
):
188+
received: list[AssistantMessageInterface] = []
189+
190+
async def on_msg(msg: AssistantMessageInterface):
191+
received.append(msg)
192+
193+
await client.query(
194+
prompt="ECHO:ok",
195+
options=make_query_options(
196+
pre_agent_invocation_commands=[
197+
CommandInterface(command="pwd", cwd="/tmp")
198+
],
199+
on_assistant_message=on_msg,
200+
),
201+
)
202+
203+
all_text = [block for msg in received for block in msg.text_blocks]
204+
assert any("/tmp" in t for t in all_text)
205+
206+
async def test_post_command_with_cwd(
207+
self, client: RuntimeUseClient, make_query_options
208+
):
209+
received: list[AssistantMessageInterface] = []
210+
211+
async def on_msg(msg: AssistantMessageInterface):
212+
received.append(msg)
213+
214+
await client.query(
215+
prompt="ECHO:ok",
216+
options=make_query_options(
217+
post_agent_invocation_commands=[
218+
CommandInterface(command="pwd", cwd="/tmp")
219+
],
220+
on_assistant_message=on_msg,
221+
),
222+
)
223+
224+
all_text = [block for msg in received for block in msg.text_blocks]
225+
assert any("/tmp" in t for t in all_text)
226+
227+
async def test_failed_pre_command_raises_error(
228+
self, client: RuntimeUseClient, make_query_options
229+
):
230+
with pytest.raises(AgentRuntimeError, match="failed with exit code"):
231+
await client.query(
232+
prompt="ECHO:should not reach",
233+
options=make_query_options(
234+
pre_agent_invocation_commands=[
235+
CommandInterface(command="exit 1")
236+
],
237+
),
238+
)
239+
240+
async def test_failed_post_command_raises_error(
241+
self, client: RuntimeUseClient, make_query_options
242+
):
243+
with pytest.raises(AgentRuntimeError, match="failed with exit code"):
244+
await client.query(
245+
prompt="ECHO:hello",
246+
options=make_query_options(
247+
post_agent_invocation_commands=[
248+
CommandInterface(command="exit 1")
249+
],
250+
),
251+
)
252+
253+
110254
class TestInvocationFieldsForwarded:
111255
async def test_fields_round_trip(
112256
self, client: RuntimeUseClient, make_query_options

packages/runtimeuse-client-python/test/llm/__init__.py

Whitespace-only changes.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import pytest
2+
3+
from test.sandbox_factories.e2b import create_e2b_runtimeuse
4+
5+
6+
@pytest.fixture(scope="session")
7+
def openai_ws_url():
8+
"""Create an E2B sandbox running runtimeuse with the OpenAI agent."""
9+
try:
10+
sandbox, ws_url = create_e2b_runtimeuse(agent="openai")
11+
except RuntimeError as exc:
12+
pytest.fail(str(exc))
13+
14+
yield ws_url
15+
16+
sandbox.kill()
17+
18+
19+
@pytest.fixture(scope="session")
20+
def claude_ws_url():
21+
"""Create an E2B sandbox running runtimeuse with the Claude agent."""
22+
try:
23+
sandbox, ws_url = create_e2b_runtimeuse(agent="claude")
24+
except RuntimeError as exc:
25+
pytest.fail(str(exc))
26+
27+
yield ws_url
28+
29+
sandbox.kill()
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""LLM integration tests using the Claude agent."""
2+
3+
import json
4+
5+
import pytest
6+
7+
from src.runtimeuse_client import (
8+
AgentRuntimeError,
9+
RuntimeUseClient,
10+
QueryOptions,
11+
QueryResult,
12+
TextResult,
13+
StructuredOutputResult,
14+
)
15+
16+
pytestmark = [pytest.mark.llm, pytest.mark.asyncio]
17+
18+
MODEL = "claude-sonnet-4-20250514"
19+
20+
STRUCTURED_SCHEMA = json.dumps(
21+
{
22+
"type": "json_schema",
23+
"schema": {
24+
"type": "object",
25+
"properties": {
26+
"greeting": {"type": "string"},
27+
},
28+
"required": ["greeting"],
29+
"additionalProperties": False,
30+
},
31+
}
32+
)
33+
34+
35+
class TestClaudeText:
36+
async def test_text_response(self, claude_ws_url: str):
37+
client = RuntimeUseClient(ws_url=claude_ws_url)
38+
result = await client.query(
39+
prompt="Say hello world",
40+
options=QueryOptions(
41+
system_prompt="Reply concisely in plain text.",
42+
model=MODEL,
43+
),
44+
)
45+
46+
assert isinstance(result, QueryResult)
47+
assert isinstance(result.data, TextResult)
48+
assert len(result.data.text) > 0
49+
50+
51+
class TestClaudeStructuredOutput:
52+
async def test_structured_response(self, claude_ws_url: str):
53+
client = RuntimeUseClient(ws_url=claude_ws_url)
54+
result = await client.query(
55+
prompt="Greet the user",
56+
options=QueryOptions(
57+
system_prompt="Reply with a greeting.",
58+
model=MODEL,
59+
output_format_json_schema_str=STRUCTURED_SCHEMA,
60+
),
61+
)
62+
63+
assert isinstance(result, QueryResult)
64+
assert isinstance(result.data, StructuredOutputResult)
65+
assert "greeting" in result.data.structured_output
66+
assert isinstance(result.data.structured_output["greeting"], str)
67+
assert len(result.data.structured_output["greeting"]) > 0
68+
69+
70+
class TestClaudeError:
71+
async def test_invalid_model_raises_error(self, claude_ws_url: str):
72+
client = RuntimeUseClient(ws_url=claude_ws_url)
73+
with pytest.raises(AgentRuntimeError):
74+
await client.query(
75+
prompt="Say hello",
76+
options=QueryOptions(
77+
system_prompt="Reply concisely.",
78+
model="nonexistent-model-xyz",
79+
),
80+
)

0 commit comments

Comments
 (0)