diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..519cb16 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,46 @@ +"""Shared test infrastructure: HTTP server, fake provider clients, etc.""" + +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class HelloHandler(BaseHTTPRequestHandler): + """A tiny HTTP handler that answers every GET with a fixed body "hello".""" + + protocol_version = "HTTP/1.0" + + def do_GET(self): + body = b"hello" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args) -> None: + pass + + +class HttpServer: + """Context manager that runs a ThreadingHTTPServer on a random port.""" + + def __init__(self, handler=None): + self._server = ThreadingHTTPServer( + ("127.0.0.1", 0), handler if handler is not None else HelloHandler + ) + self._thread: threading.Thread | None = None + + def __enter__(self): + self._thread = threading.Thread( + target=self._server.serve_forever, + name="tartarus-test-http-server", + daemon=True, + ) + self._thread.start() + return self._server + + def __exit__(self, *_args): + self._server.shutdown() + self._server.server_close() + if self._thread is not None: + self._thread.join(timeout=5) diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index 225ed02..ce2e0df 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -331,3 +331,81 @@ def test_task_cancel_mid_tool_terminates_worker_synchronously(): assert cancelled_on_return assert messages == [{"role": "user", "content": "use echo"}] + + +def test_loop_survives_unknown_tool_call(): + """When the model calls a tool not in the manifest, the broker returns an + error result and the loop continues normally (no crash, tool result recorded).""" + manifest = echo_manifest() + provider = ScriptedProvider( + [ + AssistantTurn( + text=None, + tool_calls=[ToolCall("call-1", "nonexistent", {})], + raw={"role": "assistant"}, + stop_reason="tool_calls", + ), + AssistantTurn( + text="I tried but the tool was not found.", + tool_calls=[], + raw={"role": "assistant"}, + stop_reason="end", + ), + ] + ) + loop = AgentLoop( + provider, + Broker(manifest, cast(JailBuilder, LocalJail()), PolicyEngine()), + manifest, + "system", + ) + + messages = [{"role": "user", "content": "use bad tool"}] + events = asyncio.run(_drain(loop, messages)) + + assert _text(events) == "I tried but the tool was not found." + finished = [e for e in events if isinstance(e, ToolFinished)] + assert len(finished) == 1 + assert finished[0].result.is_error + assert "unknown tool" in finished[0].result.output + + +def test_loop_brokers_multiple_parallel_tool_calls(): + """One assistant turn with two tool calls brokers both, aggregates results.""" + manifest = echo_manifest() + provider = ScriptedProvider( + [ + AssistantTurn( + text=None, + tool_calls=[ + ToolCall("call-1", "echo", {"message": "first"}), + ToolCall("call-2", "echo", {"message": "second"}), + ], + raw={"role": "assistant"}, + stop_reason="tool_calls", + ), + AssistantTurn( + text="Both tools ran.", + tool_calls=[], + raw={"role": "assistant"}, + stop_reason="end", + ), + ] + ) + loop = AgentLoop( + provider, + Broker(manifest, cast(JailBuilder, LocalJail()), PolicyEngine()), + manifest, + "system", + ) + + messages = [{"role": "user", "content": "run both"}] + events = asyncio.run(_drain(loop, messages)) + + assert _text(events) == "Both tools ran." + finished = [e for e in events if isinstance(e, ToolFinished)] + assert len(finished) == 2 + results = {f.call.id: f.result.output for f in finished} + assert results["call-1"] == "first" + assert results["call-2"] == "second" + assert len(provider.received_results) == 2 diff --git a/tests/test_audit.py b/tests/test_audit.py index 2857b80..d6d3d4c 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -1,15 +1,17 @@ import json -from tartarus.audit import AuditEvent, FileAuditLog +import pytest + +from tartarus.audit import AuditEvent, FileAuditLog, NullAuditLog +from tartarus.broker import _format_output from tartarus.jail import ExecResult from tartarus.manifest import Capability, Grant from tartarus.models import ToolResult from tartarus.policy import Decision -def test_file_audit_log_appends_jsonl_record(tmp_path): - audit_path = tmp_path / "audit" / "events.jsonl" - capability = Capability( +def _echo_capability(): + return Capability( name="echo", description="Echo.", policy="auto", @@ -17,11 +19,14 @@ def test_file_audit_log_appends_jsonl_record(tmp_path): grants=Grant(package_bins=["/nix/store/jq/bin"], writable=["artifacts"]), runner="echo hello", ) - event = AuditEvent( + + +def _full_event(): + return AuditEvent( call_id="call-1", tool_name="echo", arguments={"message": "hello"}, - capability=capability, + capability=_echo_capability(), command="echo hello", decision=Decision(True, "auto policy", "auto"), exec_result=ExecResult( @@ -33,7 +38,11 @@ def test_file_audit_log_appends_jsonl_record(tmp_path): result=ToolResult("call-1", "hello", is_error=False), ) - FileAuditLog(str(audit_path)).record(event) + +def test_file_audit_log_appends_jsonl_record(tmp_path): + audit_path = tmp_path / "audit" / "events.jsonl" + + FileAuditLog(str(audit_path)).record(_full_event()) lines = audit_path.read_text().splitlines() assert len(lines) == 1 @@ -59,3 +68,55 @@ def test_file_audit_log_appends_jsonl_record(tmp_path): assert record["network_summary"] == "proxy decisions: 1 allowed, 0 blocked" assert record["output_length"] == 5 assert record["is_error"] is False + + +def test_file_audit_log_appends_multiple_events(tmp_path): + audit_path = tmp_path / "audit" / "events.jsonl" + + FileAuditLog(str(audit_path)).record(_full_event()) + FileAuditLog(str(audit_path)).record(_full_event()) + + assert len(audit_path.read_text().splitlines()) == 2 + + +def test_audit_event_serializes_none_fields(tmp_path): + """Events from broker-rejection paths (unknown tool, jail error) carry None + for the optional fields that didn't populate.""" + event = AuditEvent( + call_id="call-2", + tool_name="unknown", + arguments={}, + result=ToolResult("call-2", "error: unknown tool", is_error=True), + command=None, + decision=None, + exec_result=None, + broker_error="unknown tool", + ) + audit_path = tmp_path / "audit" / "events.jsonl" + FileAuditLog(str(audit_path)).record(event) + + record = json.loads(audit_path.read_text().splitlines()[0]) + assert record["call_id"] == "call-2" + assert record["capability_name"] is None + assert record["command"] is None + assert record["policy"] is None + assert record["exit_code"] is None + assert record["network_summary"] is None + assert record["broker_error"] == "unknown tool" + + +def test_null_audit_log_is_noop(): + NullAuditLog().record(_full_event()) # must not raise + + +@pytest.mark.parametrize( + "stdout,stderr,output_truncate,expected", + [ + ("a", "b", 100, "a\nb"), + ("", "", 100, "(no output)"), + ("", "err", 100, "err"), + ("abcdef", "", 3, "abc\n...(truncated)"), + ], +) +def test_format_output(stdout, stderr, output_truncate, expected): + assert _format_output(stdout, stderr, output_truncate) == expected diff --git a/tests/test_broker.py b/tests/test_broker.py index 9edf91f..a801723 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -153,8 +153,10 @@ def test_nonzero_exit_is_reported_as_error(): def test_undeclared_timeout_runs_unbounded(): - # A capability without a declared timeout runs with no ceiling: the jail - # receives None, which the process wait loop treats as "wait forever". + # A capability without a declared timeout has capability.timeout = None. + # The broker passes this explicitly to the jail, which overrides the jail's + # own default (30s). asyncio.timeout(None) waits indefinitely, so the + # command runs with no ceiling until the process exits. jail = FakeJail(result=ExecResult(0, "hi", "")) broker = _broker(jail) @@ -518,3 +520,41 @@ def test_shell_injection_is_escaped_before_it_reaches_the_jail(): # The payload is quoted into a single argument; the injected command can't run. assert jail.exec_commands == ["echo 'hi; echo pwned'"] + + +def test_validate_args_enforces_boolean_and_array_types(): + params = { + "verbose": Param(type="boolean", description=""), + "items": Param(type="array", description=""), + } + + assert validate_args({"verbose": True, "items": [1, 2]}, params) is None + + error = validate_args({"verbose": "yes", "items": [1]}, params) + assert error is not None + assert "must be a boolean" in error + + error = validate_args({"verbose": True, "items": "not-a-list"}, params) + assert error is not None + assert "must be a array" in error + + error = validate_args({"verbose": True, "items": True}, params) + assert error is not None + assert "must be a array" in error + + +def test_background_jail_error_is_reported(): + jail = FakeJail(error=JailError("popen failed")) + broker = Broker( + _background_manifest(), + jail, + PolicyEngine(prompt=lambda *_: True), + registry=FakeRegistry(), + ) + + result = _handle(broker, _call("run_bg", {"command": "sleep 1"})) + + assert result.is_error + assert "jail error" in result.output + # The command was interpolated and attempted; the jail error surface proves + # the broker catches and reports failures from exec_background properly. diff --git a/tests/test_cli.py b/tests/test_cli.py index 57be6ba..e031d31 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ import pytest from tartarus.agent_loop import AgentLoop from tartarus.background import BackgroundRegistry +from tartarus.broker import Broker from tartarus.cli import ( SessionFlags, _bundle_manifest_source, @@ -13,6 +14,10 @@ from tartarus.cli import ( _run_one_shot, ) from tartarus.config import ConfigError +from tartarus.jail import JailBuilder +from tartarus.policy import PolicyEngine +from tartarus.provider.base import Provider +from tests.manifest_fixtures import echo_manifest def test_parse_agent_selector_picks_named_agent(): @@ -138,9 +143,16 @@ def test_run_one_shot_returns_one_when_background_reaction_fails(monkeypatch): monkeypatch.setattr("tartarus.cli._send", fake_send) monkeypatch.setattr("tartarus.cli._drain_notice", fake_drain) + loop = AgentLoop( + provider=cast(Provider, None), + broker=Broker(echo_manifest(), cast(JailBuilder, None), PolicyEngine()), + manifest=echo_manifest(), + system_prompt="test", + ) + result = asyncio.run( _run_one_shot( - cast(AgentLoop, None), + loop, "prompt", [], None, diff --git a/tests/test_jail.py b/tests/test_jail.py index c836a08..df4951b 100644 --- a/tests/test_jail.py +++ b/tests/test_jail.py @@ -10,8 +10,6 @@ import shutil import shlex import subprocess import sys -import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import tartarus.jail import pytest @@ -19,6 +17,7 @@ import pytest from tartarus.shell import ShellError, resolve_minimal_shell_path from tartarus.jail import JailBuilder, JailError from tartarus.manifest import Grant +from tests.helpers import HttpServer _NEEDS_SANDBOX = pytest.mark.skipif( shutil.which("bwrap") is None or shutil.which("nix") is None, @@ -178,7 +177,7 @@ def test_proxy_jail_sets_proxy_environment(tmp_path): def test_proxy_jail_routes_curl_through_allowed_host( tmp_path, shell_path, shell_closure ): - with _HttpServer() as upstream: + with HttpServer() as upstream: upstream_host, upstream_port = upstream.server_address curl_bins = _curl_bin_dirs() jail = JailBuilder( @@ -205,7 +204,7 @@ def test_proxy_jail_routes_curl_through_allowed_host( @_NEEDS_SANDBOX def test_proxy_jail_blocks_unlisted_host(tmp_path, shell_path, shell_closure): - with _HttpServer() as upstream: + with HttpServer() as upstream: upstream_host, upstream_port = upstream.server_address curl_bins = _curl_bin_dirs() jail = JailBuilder( @@ -385,38 +384,6 @@ def test_exec_cancellation_terminates_unrestricted_process(tmp_path): assert lines == ["started\n"] -class _HelloHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.0" - - def do_GET(self): - body = b"hello" - self.send_response(200) - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) - - def log_message(self, format: str, *args) -> None: - pass - - -class _HttpServer: - def __enter__(self): - self._server = ThreadingHTTPServer(("127.0.0.1", 0), _HelloHandler) - self._thread = threading.Thread( - target=self._server.serve_forever, - name="tartarus-nix-jail-test-http-server", - daemon=True, - ) - self._thread.start() - return self._server - - def __exit__(self, *_args): - self._server.shutdown() - self._server.server_close() - self._thread.join(timeout=5) - - def _curl_bin_dirs() -> list[str]: try: return [resolve_minimal_shell_path(("nixpkgs#curl",))] diff --git a/tests/test_manifest_loader.py b/tests/test_manifest_loader.py index 52bc8ae..1af5848 100644 --- a/tests/test_manifest_loader.py +++ b/tests/test_manifest_loader.py @@ -128,27 +128,22 @@ def test_non_store_ca_bundle_is_rejected(): build_manifest_from_raw(raw) -def test_missing_shell_closure_is_rejected(): - raw = _valid_raw() - del raw["shellClosure"] - - with pytest.raises(ManifestError, match="shellClosure.*required"): - build_manifest_from_raw(raw) - - -def test_non_store_shell_closure_is_rejected(): - raw = _valid_raw() - raw["shellClosure"] = "/tmp/store-paths" - - with pytest.raises(ManifestError, match="shellClosure.*under /nix/store"): - build_manifest_from_raw(raw) - - -def test_shell_closure_without_store_paths_suffix_is_rejected(): +@pytest.mark.parametrize( + ("modification", "expected_match"), + [ + (None, "shellClosure.*required"), + ("/tmp/store-paths", "shellClosure.*under /nix/store"), + ("/nix/store/shell-closure/paths", "shellClosure.*store-paths"), + ], +) +def test_shell_closure_validation(modification, expected_match): raw = _valid_raw() - raw["shellClosure"] = "/nix/store/shell-closure/paths" + if modification is None: + del raw["shellClosure"] + else: + raw["shellClosure"] = modification - with pytest.raises(ManifestError, match="shellClosure.*store-paths"): + with pytest.raises(ManifestError, match=expected_match): build_manifest_from_raw(raw) @@ -339,43 +334,24 @@ def test_non_string_capability_description_is_rejected(): build_manifest_from_raw(raw) -def test_non_object_params_is_rejected(): - raw = _valid_raw() - raw["capabilities"]["echo"]["params"] = "bad" - - with pytest.raises(ManifestError, match="params.*object"): - build_manifest_from_raw(raw) - - -def test_non_object_param_body_is_rejected(): - raw = _valid_raw() - raw["capabilities"]["echo"]["params"]["message"] = "bad" - - with pytest.raises(ManifestError, match="params.message"): - build_manifest_from_raw(raw) - - -def test_non_boolean_param_required_is_rejected(): - raw = _valid_raw() - raw["capabilities"]["echo"]["params"]["message"]["required"] = "yes" - - with pytest.raises(ManifestError, match="required.*valid boolean"): - build_manifest_from_raw(raw) - - -def test_non_string_param_description_is_rejected(): - raw = _valid_raw() - raw["capabilities"]["echo"]["params"]["message"]["description"] = ["bad"] - - with pytest.raises(ManifestError, match="description.*valid string"): - build_manifest_from_raw(raw) - - -def test_non_list_param_enum_is_rejected(): +@pytest.mark.parametrize( + ("field_path", "bad_value", "expected_match"), + [ + (("echo", "params"), "bad", "params.*object"), + (("echo", "params", "message"), "bad", "params.message"), + (("echo", "params", "message", "required"), "yes", "required.*valid boolean"), + (("echo", "params", "message", "description"), ["bad"], "description.*valid string"), + (("echo", "params", "message", "enum"), "red", "enum.*valid list"), + ], +) +def test_param_shape_validation(field_path, bad_value, expected_match): raw = _valid_raw() - raw["capabilities"]["echo"]["params"]["message"]["enum"] = "red" + target = raw["capabilities"] + for key in field_path[:-1]: + target = target[key] + target[field_path[-1]] = bad_value - with pytest.raises(ManifestError, match="enum.*valid list"): + with pytest.raises(ManifestError, match=expected_match): build_manifest_from_raw(raw) diff --git a/tests/test_network_proxy.py b/tests/test_network_proxy.py index 8081164..182eac6 100644 --- a/tests/test_network_proxy.py +++ b/tests/test_network_proxy.py @@ -1,27 +1,11 @@ import http.client -import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from tartarus.network_proxy import FilteringProxy - - -class _HelloHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.0" - - def do_GET(self): - body = b"hello" - self.send_response(200) - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) - - def log_message(self, format: str, *args) -> None: - pass +from tests.helpers import HttpServer def test_filtering_proxy_allows_listed_host(): - with _HttpServer() as upstream: + with HttpServer() as upstream: upstream_host, upstream_port = upstream.server_address with FilteringProxy([f"{upstream_host}:{upstream_port}"]) as proxy: @@ -39,7 +23,7 @@ def test_filtering_proxy_allows_listed_host(): def test_filtering_proxy_denies_unlisted_host(): - with _HttpServer() as upstream: + with HttpServer() as upstream: upstream_host, upstream_port = upstream.server_address with FilteringProxy(["example.com:80"]) as proxy: @@ -56,7 +40,7 @@ def test_filtering_proxy_denies_unlisted_host(): def test_filtering_proxy_wildcard_allows_any_host(): - with _HttpServer() as upstream: + with HttpServer() as upstream: upstream_host, upstream_port = upstream.server_address with FilteringProxy(["*"]) as proxy: @@ -73,23 +57,6 @@ def test_filtering_proxy_wildcard_allows_any_host(): ) -class _HttpServer: - def __enter__(self): - self._server = ThreadingHTTPServer(("127.0.0.1", 0), _HelloHandler) - self._thread = threading.Thread( - target=self._server.serve_forever, - name="tartarus-nix-test-http-server", - daemon=True, - ) - self._thread.start() - return self._server - - def __exit__(self, *_args): - self._server.shutdown() - self._server.server_close() - self._thread.join(timeout=5) - - def _proxy_address(proxy_url: str) -> tuple[str, int]: host_port = proxy_url.removeprefix("http://") host, port = host_port.rsplit(":", 1) diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..f21a45d --- /dev/null +++ b/tests/test_process.py @@ -0,0 +1,22 @@ +import pytest + +from tartarus.process import ProcessError, run_checked + + +def test_run_checked_returns_stdout(): + assert run_checked(["echo", "-n", "hello"]) == "hello" + + +def test_run_checked_raises_on_empty_command(): + with pytest.raises(ProcessError, match="empty"): + run_checked([]) + + +def test_run_checked_raises_on_missing_binary(): + with pytest.raises(ProcessError, match="not found"): + run_checked(["nonexistent_command_xyz"]) + + +def test_run_checked_raises_on_nonzero_exit(): + with pytest.raises(ProcessError, match="failed"): + run_checked(["sh", "-c", "exit 1"]) diff --git a/tests/test_provider.py b/tests/test_provider.py index 0bef08a..abd0d00 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -1,5 +1,7 @@ import asyncio +from typing import Any + import pytest from tartarus.models import TextDelta, ToolResult, TurnComplete @@ -7,13 +9,69 @@ from tartarus.provider.openai_compat import OpenAICompatProvider, ProviderError from tests.manifest_fixtures import echo_manifest -def _provider(): - return OpenAICompatProvider( +def _provider(**overrides: Any): + kwargs: dict[str, Any] = dict( base_url="https://example.test/v1", api_key="secret", model="opencode/gpt-5.5", max_tokens=128, ) + kwargs.update(overrides) + return OpenAICompatProvider(**kwargs) + + +# -- shared fake HTTP stack --------------------------------------------------- + + +class _FakeTransport: + """Replay supplied SSE lines as a streaming response.""" + + def __init__(self, lines: list[str], status_code: int = 200): + self._lines = lines + self.status_code = status_code + + async def aiter_lines(self): + for line in self._lines: + yield line + + async def aread(self): + return b"error body" + + +_stored_stream: _FakeTransport = _FakeTransport([]) + + +class _FakeClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def stream(self, *args, **kwargs): + return _make_stream(_stored_stream) + + +def _make_stream(transport): + class _Stream: + def __init__(self): + pass + + async def __aenter__(self): + return transport + + async def __aexit__(self, *exc): + return False + + return _Stream() + + +def _configure_fake_stream(lines, status_code=200): + global _stored_stream + _stored_stream = _FakeTransport(lines, status_code) def test_adapt_tools_wraps_in_function_envelope(): @@ -25,13 +83,7 @@ def test_adapt_tools_wraps_in_function_envelope(): def test_build_body_includes_sampling_when_set(): - provider = OpenAICompatProvider( - base_url="https://example.test/v1", - api_key="secret", - model="opencode/gpt-5.5", - max_tokens=128, - sampling={"temperature": 0, "top_p": 0.9}, - ) + provider = _provider(sampling={"temperature": 0, "top_p": 0.9}) body = provider._build_body("sys", [], []) @@ -47,11 +99,7 @@ def test_build_body_omits_sampling_when_unset(): def test_build_body_sampling_cannot_override_reserved_fields(): - provider = OpenAICompatProvider( - base_url="https://example.test/v1", - api_key="secret", - model="opencode/gpt-5.5", - max_tokens=128, + provider = _provider( sampling={"model": "other", "max_tokens": 999, "messages": []}, ) @@ -212,37 +260,8 @@ def test_stream_yields_text_deltas_then_turn_complete(monkeypatch): "data: [DONE]", ] - class FakeResponse: - status_code = 200 - - async def aiter_lines(self): - for line in chunks: - yield line - - async def aread(self): # pragma: no cover - only used on error paths - return b"" - - class FakeStream: - async def __aenter__(self): - return FakeResponse() - - async def __aexit__(self, *exc): - return False - - class FakeClient: - def __init__(self, *args, **kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - def stream(self, *args, **kwargs): - return FakeStream() - - monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", FakeClient) + _configure_fake_stream(chunks) + monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) async def collect(): return [e async for e in provider.stream("sys", [], [])] @@ -266,23 +285,79 @@ def test_stream_raises_on_bad_sse_chunk(monkeypatch, bad_line, expected_msg): """Invalid JSON or non-object SSE payloads abort the stream with ProviderError.""" provider = _provider() - class FakeResponse: - status_code = 200 + _configure_fake_stream([bad_line]) + monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) - async def aiter_lines(self): - yield bad_line + async def collect(): + return [e async for e in provider.stream("sys", [], [])] - async def aread(self): # pragma: no cover - only used on error paths - return b"" + with pytest.raises(ProviderError, match=expected_msg): + asyncio.run(collect()) - class FakeStream: - async def __aenter__(self): - return FakeResponse() - async def __aexit__(self, *exc): - return False +def test_stream_raises_on_http_error(monkeypatch): + """A non-200 response body aborts the stream with ProviderError.""" + provider = _provider() - class FakeClient: + _configure_fake_stream([], status_code=500) + monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) + + async def collect(): + return [e async for e in provider.stream("sys", [], [])] + + with pytest.raises(ProviderError, match="backend returned HTTP 500"): + asyncio.run(collect()) + + +def test_stream_passes_tool_call_deltas(monkeypatch): + """SSE chunks with tool_calls deltas are accumulated into the final turn.""" + provider = _provider() + + chunks = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"echo"}}]}}]}', # noqa: E501 + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"mess"}}]}}]}', # noqa: E501 + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"age\\": \\"hi\\"}"}}]}}]}', # noqa: E501 + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}', + "data: [DONE]", + ] + + _configure_fake_stream(chunks) + monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) + + async def collect(): + return [e async for e in provider.stream("sys", [], [])] + + events = asyncio.run(collect()) + + assert isinstance(events[-1], TurnComplete) + turn = events[-1].turn + assert turn.stop_reason == "tool_calls" + assert len(turn.tool_calls) == 1 + assert turn.tool_calls[0].name == "echo" + assert turn.tool_calls[0].arguments == {"message": "hi"} + + +def test_complete_round_trips(monkeypatch): + """The non-streaming complete() method parses a response into an AssistantTurn.""" + provider = _provider() + + response_json = { + "choices": [ + { + "finish_reason": "stop", + "message": {"content": "Hello, world"}, + } + ] + } + + class _FakePostResponse: + status_code = 200 + text = "ok" + + def json(self): + return response_json + + class _FakePostClient: def __init__(self, *args, **kwargs): pass @@ -292,13 +367,14 @@ def test_stream_raises_on_bad_sse_chunk(monkeypatch, bad_line, expected_msg): async def __aexit__(self, *exc): return False - def stream(self, *args, **kwargs): - return FakeStream() - - monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", FakeClient) + async def post(self, *args, **kwargs): + return _FakePostResponse() - async def collect(): - return [e async for e in provider.stream("sys", [], [])] + monkeypatch.setattr( + "tartarus.provider.openai_compat.httpx.AsyncClient", _FakePostClient + ) - with pytest.raises(ProviderError, match=expected_msg): - asyncio.run(collect()) + turn = asyncio.run(provider.complete("sys", [], [])) + assert turn.text == "Hello, world" + assert turn.stop_reason == "end" + assert turn.tool_calls == [] diff --git a/tests/test_session.py b/tests/test_session.py index 8a37b02..5fc7b3b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -130,3 +130,18 @@ def test_first_user_message_preview(tmp_path): assert ( SessionStore(str(tmp_path), "s1").first_user_message() == "summarize the repo" ) + + +def test_first_user_message_returns_none_for_assistant_only(tmp_path): + store = SessionStore(str(tmp_path), "s1") + store.append([{"role": "assistant", "content": "hello"}]) + + assert SessionStore(str(tmp_path), "s1").first_user_message() is None + + +def test_list_ids_ignores_non_jsonl_files(tmp_path): + for name in ("a.jsonl", "b.txt", "c.log"): + (tmp_path / name).write_text("{}") + + ids = SessionStore.list_ids(str(tmp_path)) + assert ids == ["a"] diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000..848ed29 --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,37 @@ +import pytest + +from tartarus.process import ProcessError +from tartarus.shell import ShellError, resolve_minimal_shell_path + + +def test_resolve_minimal_shell_path_propagates_build_failure(monkeypatch): + def fail(_command): + raise ProcessError("cannot build `nixpkgs#coreutils`: nix not found") + + monkeypatch.setattr("tartarus.shell.run_checked", fail) + + with pytest.raises(ShellError, match="cannot build"): + resolve_minimal_shell_path() + + +def test_resolve_minimal_shell_path_rejects_empty_output(monkeypatch): + def empty(_command): + return "" + + monkeypatch.setattr("tartarus.shell.run_checked", empty) + + with pytest.raises(ShellError, match="produced no store path"): + resolve_minimal_shell_path() + + +def test_resolve_minimal_shell_path_rejects_no_bin_directory(monkeypatch, tmp_path): + no_bin = tmp_path / "no-bin" + no_bin.mkdir() + + def fake_build(_command): + return f"{no_bin}\n" + + monkeypatch.setattr("tartarus.shell.run_checked", fake_build) + + with pytest.raises(ShellError, match="no output of"): + resolve_minimal_shell_path()