A declarative, hermetic harness for Nix-defined agents.
0

Configure Feed

Select the types of activity you want to include in your feed.

tests: expanded edge case coverage

Aly Raffauf (Jun 28, 2026, 12:09 AM EDT) 94c48d23 197cfabc

+499 -202
+46
tests/helpers.py
··· 1 + """Shared test infrastructure: HTTP server, fake provider clients, etc.""" 2 + 3 + import threading 4 + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 5 + 6 + 7 + class HelloHandler(BaseHTTPRequestHandler): 8 + """A tiny HTTP handler that answers every GET with a fixed body "hello".""" 9 + 10 + protocol_version = "HTTP/1.0" 11 + 12 + def do_GET(self): 13 + body = b"hello" 14 + self.send_response(200) 15 + self.send_header("Content-Length", str(len(body))) 16 + self.send_header("Connection", "close") 17 + self.end_headers() 18 + self.wfile.write(body) 19 + 20 + def log_message(self, format: str, *args) -> None: 21 + pass 22 + 23 + 24 + class HttpServer: 25 + """Context manager that runs a ThreadingHTTPServer on a random port.""" 26 + 27 + def __init__(self, handler=None): 28 + self._server = ThreadingHTTPServer( 29 + ("127.0.0.1", 0), handler if handler is not None else HelloHandler 30 + ) 31 + self._thread: threading.Thread | None = None 32 + 33 + def __enter__(self): 34 + self._thread = threading.Thread( 35 + target=self._server.serve_forever, 36 + name="tartarus-test-http-server", 37 + daemon=True, 38 + ) 39 + self._thread.start() 40 + return self._server 41 + 42 + def __exit__(self, *_args): 43 + self._server.shutdown() 44 + self._server.server_close() 45 + if self._thread is not None: 46 + self._thread.join(timeout=5)
+78
tests/test_agent_loop.py
··· 331 331 332 332 assert cancelled_on_return 333 333 assert messages == [{"role": "user", "content": "use echo"}] 334 + 335 + 336 + def test_loop_survives_unknown_tool_call(): 337 + """When the model calls a tool not in the manifest, the broker returns an 338 + error result and the loop continues normally (no crash, tool result recorded).""" 339 + manifest = echo_manifest() 340 + provider = ScriptedProvider( 341 + [ 342 + AssistantTurn( 343 + text=None, 344 + tool_calls=[ToolCall("call-1", "nonexistent", {})], 345 + raw={"role": "assistant"}, 346 + stop_reason="tool_calls", 347 + ), 348 + AssistantTurn( 349 + text="I tried but the tool was not found.", 350 + tool_calls=[], 351 + raw={"role": "assistant"}, 352 + stop_reason="end", 353 + ), 354 + ] 355 + ) 356 + loop = AgentLoop( 357 + provider, 358 + Broker(manifest, cast(JailBuilder, LocalJail()), PolicyEngine()), 359 + manifest, 360 + "system", 361 + ) 362 + 363 + messages = [{"role": "user", "content": "use bad tool"}] 364 + events = asyncio.run(_drain(loop, messages)) 365 + 366 + assert _text(events) == "I tried but the tool was not found." 367 + finished = [e for e in events if isinstance(e, ToolFinished)] 368 + assert len(finished) == 1 369 + assert finished[0].result.is_error 370 + assert "unknown tool" in finished[0].result.output 371 + 372 + 373 + def test_loop_brokers_multiple_parallel_tool_calls(): 374 + """One assistant turn with two tool calls brokers both, aggregates results.""" 375 + manifest = echo_manifest() 376 + provider = ScriptedProvider( 377 + [ 378 + AssistantTurn( 379 + text=None, 380 + tool_calls=[ 381 + ToolCall("call-1", "echo", {"message": "first"}), 382 + ToolCall("call-2", "echo", {"message": "second"}), 383 + ], 384 + raw={"role": "assistant"}, 385 + stop_reason="tool_calls", 386 + ), 387 + AssistantTurn( 388 + text="Both tools ran.", 389 + tool_calls=[], 390 + raw={"role": "assistant"}, 391 + stop_reason="end", 392 + ), 393 + ] 394 + ) 395 + loop = AgentLoop( 396 + provider, 397 + Broker(manifest, cast(JailBuilder, LocalJail()), PolicyEngine()), 398 + manifest, 399 + "system", 400 + ) 401 + 402 + messages = [{"role": "user", "content": "run both"}] 403 + events = asyncio.run(_drain(loop, messages)) 404 + 405 + assert _text(events) == "Both tools ran." 406 + finished = [e for e in events if isinstance(e, ToolFinished)] 407 + assert len(finished) == 2 408 + results = {f.call.id: f.result.output for f in finished} 409 + assert results["call-1"] == "first" 410 + assert results["call-2"] == "second" 411 + assert len(provider.received_results) == 2
+68 -7
tests/test_audit.py
··· 1 1 import json 2 2 3 - from tartarus.audit import AuditEvent, FileAuditLog 3 + import pytest 4 + 5 + from tartarus.audit import AuditEvent, FileAuditLog, NullAuditLog 6 + from tartarus.broker import _format_output 4 7 from tartarus.jail import ExecResult 5 8 from tartarus.manifest import Capability, Grant 6 9 from tartarus.models import ToolResult 7 10 from tartarus.policy import Decision 8 11 9 12 10 - def test_file_audit_log_appends_jsonl_record(tmp_path): 11 - audit_path = tmp_path / "audit" / "events.jsonl" 12 - capability = Capability( 13 + def _echo_capability(): 14 + return Capability( 13 15 name="echo", 14 16 description="Echo.", 15 17 policy="auto", ··· 17 19 grants=Grant(package_bins=["/nix/store/jq/bin"], writable=["artifacts"]), 18 20 runner="echo hello", 19 21 ) 20 - event = AuditEvent( 22 + 23 + 24 + def _full_event(): 25 + return AuditEvent( 21 26 call_id="call-1", 22 27 tool_name="echo", 23 28 arguments={"message": "hello"}, 24 - capability=capability, 29 + capability=_echo_capability(), 25 30 command="echo hello", 26 31 decision=Decision(True, "auto policy", "auto"), 27 32 exec_result=ExecResult( ··· 33 38 result=ToolResult("call-1", "hello", is_error=False), 34 39 ) 35 40 36 - FileAuditLog(str(audit_path)).record(event) 41 + 42 + def test_file_audit_log_appends_jsonl_record(tmp_path): 43 + audit_path = tmp_path / "audit" / "events.jsonl" 44 + 45 + FileAuditLog(str(audit_path)).record(_full_event()) 37 46 38 47 lines = audit_path.read_text().splitlines() 39 48 assert len(lines) == 1 ··· 59 68 assert record["network_summary"] == "proxy decisions: 1 allowed, 0 blocked" 60 69 assert record["output_length"] == 5 61 70 assert record["is_error"] is False 71 + 72 + 73 + def test_file_audit_log_appends_multiple_events(tmp_path): 74 + audit_path = tmp_path / "audit" / "events.jsonl" 75 + 76 + FileAuditLog(str(audit_path)).record(_full_event()) 77 + FileAuditLog(str(audit_path)).record(_full_event()) 78 + 79 + assert len(audit_path.read_text().splitlines()) == 2 80 + 81 + 82 + def test_audit_event_serializes_none_fields(tmp_path): 83 + """Events from broker-rejection paths (unknown tool, jail error) carry None 84 + for the optional fields that didn't populate.""" 85 + event = AuditEvent( 86 + call_id="call-2", 87 + tool_name="unknown", 88 + arguments={}, 89 + result=ToolResult("call-2", "error: unknown tool", is_error=True), 90 + command=None, 91 + decision=None, 92 + exec_result=None, 93 + broker_error="unknown tool", 94 + ) 95 + audit_path = tmp_path / "audit" / "events.jsonl" 96 + FileAuditLog(str(audit_path)).record(event) 97 + 98 + record = json.loads(audit_path.read_text().splitlines()[0]) 99 + assert record["call_id"] == "call-2" 100 + assert record["capability_name"] is None 101 + assert record["command"] is None 102 + assert record["policy"] is None 103 + assert record["exit_code"] is None 104 + assert record["network_summary"] is None 105 + assert record["broker_error"] == "unknown tool" 106 + 107 + 108 + def test_null_audit_log_is_noop(): 109 + NullAuditLog().record(_full_event()) # must not raise 110 + 111 + 112 + @pytest.mark.parametrize( 113 + "stdout,stderr,output_truncate,expected", 114 + [ 115 + ("a", "b", 100, "a\nb"), 116 + ("", "", 100, "(no output)"), 117 + ("", "err", 100, "err"), 118 + ("abcdef", "", 3, "abc\n...(truncated)"), 119 + ], 120 + ) 121 + def test_format_output(stdout, stderr, output_truncate, expected): 122 + assert _format_output(stdout, stderr, output_truncate) == expected
+42 -2
tests/test_broker.py
··· 153 153 154 154 155 155 def test_undeclared_timeout_runs_unbounded(): 156 - # A capability without a declared timeout runs with no ceiling: the jail 157 - # receives None, which the process wait loop treats as "wait forever". 156 + # A capability without a declared timeout has capability.timeout = None. 157 + # The broker passes this explicitly to the jail, which overrides the jail's 158 + # own default (30s). asyncio.timeout(None) waits indefinitely, so the 159 + # command runs with no ceiling until the process exits. 158 160 jail = FakeJail(result=ExecResult(0, "hi", "")) 159 161 broker = _broker(jail) 160 162 ··· 518 520 519 521 # The payload is quoted into a single argument; the injected command can't run. 520 522 assert jail.exec_commands == ["echo 'hi; echo pwned'"] 523 + 524 + 525 + def test_validate_args_enforces_boolean_and_array_types(): 526 + params = { 527 + "verbose": Param(type="boolean", description=""), 528 + "items": Param(type="array", description=""), 529 + } 530 + 531 + assert validate_args({"verbose": True, "items": [1, 2]}, params) is None 532 + 533 + error = validate_args({"verbose": "yes", "items": [1]}, params) 534 + assert error is not None 535 + assert "must be a boolean" in error 536 + 537 + error = validate_args({"verbose": True, "items": "not-a-list"}, params) 538 + assert error is not None 539 + assert "must be a array" in error 540 + 541 + error = validate_args({"verbose": True, "items": True}, params) 542 + assert error is not None 543 + assert "must be a array" in error 544 + 545 + 546 + def test_background_jail_error_is_reported(): 547 + jail = FakeJail(error=JailError("popen failed")) 548 + broker = Broker( 549 + _background_manifest(), 550 + jail, 551 + PolicyEngine(prompt=lambda *_: True), 552 + registry=FakeRegistry(), 553 + ) 554 + 555 + result = _handle(broker, _call("run_bg", {"command": "sleep 1"})) 556 + 557 + assert result.is_error 558 + assert "jail error" in result.output 559 + # The command was interpolated and attempted; the jail error surface proves 560 + # the broker catches and reports failures from exec_background properly.
+13 -1
tests/test_cli.py
··· 5 5 6 6 from tartarus.agent_loop import AgentLoop 7 7 from tartarus.background import BackgroundRegistry 8 + from tartarus.broker import Broker 8 9 from tartarus.cli import ( 9 10 SessionFlags, 10 11 _bundle_manifest_source, ··· 13 14 _run_one_shot, 14 15 ) 15 16 from tartarus.config import ConfigError 17 + from tartarus.jail import JailBuilder 18 + from tartarus.policy import PolicyEngine 19 + from tartarus.provider.base import Provider 20 + from tests.manifest_fixtures import echo_manifest 16 21 17 22 18 23 def test_parse_agent_selector_picks_named_agent(): ··· 138 143 monkeypatch.setattr("tartarus.cli._send", fake_send) 139 144 monkeypatch.setattr("tartarus.cli._drain_notice", fake_drain) 140 145 146 + loop = AgentLoop( 147 + provider=cast(Provider, None), 148 + broker=Broker(echo_manifest(), cast(JailBuilder, None), PolicyEngine()), 149 + manifest=echo_manifest(), 150 + system_prompt="test", 151 + ) 152 + 141 153 result = asyncio.run( 142 154 _run_one_shot( 143 - cast(AgentLoop, None), 155 + loop, 144 156 "prompt", 145 157 [], 146 158 None,
+3 -36
tests/test_jail.py
··· 10 10 import shlex 11 11 import subprocess 12 12 import sys 13 - import threading 14 - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 15 13 16 14 import tartarus.jail 17 15 import pytest ··· 19 17 from tartarus.shell import ShellError, resolve_minimal_shell_path 20 18 from tartarus.jail import JailBuilder, JailError 21 19 from tartarus.manifest import Grant 20 + from tests.helpers import HttpServer 22 21 23 22 _NEEDS_SANDBOX = pytest.mark.skipif( 24 23 shutil.which("bwrap") is None or shutil.which("nix") is None, ··· 178 177 def test_proxy_jail_routes_curl_through_allowed_host( 179 178 tmp_path, shell_path, shell_closure 180 179 ): 181 - with _HttpServer() as upstream: 180 + with HttpServer() as upstream: 182 181 upstream_host, upstream_port = upstream.server_address 183 182 curl_bins = _curl_bin_dirs() 184 183 jail = JailBuilder( ··· 205 204 206 205 @_NEEDS_SANDBOX 207 206 def test_proxy_jail_blocks_unlisted_host(tmp_path, shell_path, shell_closure): 208 - with _HttpServer() as upstream: 207 + with HttpServer() as upstream: 209 208 upstream_host, upstream_port = upstream.server_address 210 209 curl_bins = _curl_bin_dirs() 211 210 jail = JailBuilder( ··· 383 382 384 383 # The process was killed during its sleep, so its later output never arrives. 385 384 assert lines == ["started\n"] 386 - 387 - 388 - class _HelloHandler(BaseHTTPRequestHandler): 389 - protocol_version = "HTTP/1.0" 390 - 391 - def do_GET(self): 392 - body = b"hello" 393 - self.send_response(200) 394 - self.send_header("Content-Length", str(len(body))) 395 - self.send_header("Connection", "close") 396 - self.end_headers() 397 - self.wfile.write(body) 398 - 399 - def log_message(self, format: str, *args) -> None: 400 - pass 401 - 402 - 403 - class _HttpServer: 404 - def __enter__(self): 405 - self._server = ThreadingHTTPServer(("127.0.0.1", 0), _HelloHandler) 406 - self._thread = threading.Thread( 407 - target=self._server.serve_forever, 408 - name="tartarus-nix-jail-test-http-server", 409 - daemon=True, 410 - ) 411 - self._thread.start() 412 - return self._server 413 - 414 - def __exit__(self, *_args): 415 - self._server.shutdown() 416 - self._server.server_close() 417 - self._thread.join(timeout=5) 418 385 419 386 420 387 def _curl_bin_dirs() -> list[str]:
+30 -54
tests/test_manifest_loader.py
··· 128 128 build_manifest_from_raw(raw) 129 129 130 130 131 - def test_missing_shell_closure_is_rejected(): 131 + @pytest.mark.parametrize( 132 + ("modification", "expected_match"), 133 + [ 134 + (None, "shellClosure.*required"), 135 + ("/tmp/store-paths", "shellClosure.*under /nix/store"), 136 + ("/nix/store/shell-closure/paths", "shellClosure.*store-paths"), 137 + ], 138 + ) 139 + def test_shell_closure_validation(modification, expected_match): 132 140 raw = _valid_raw() 133 - del raw["shellClosure"] 141 + if modification is None: 142 + del raw["shellClosure"] 143 + else: 144 + raw["shellClosure"] = modification 134 145 135 - with pytest.raises(ManifestError, match="shellClosure.*required"): 136 - build_manifest_from_raw(raw) 137 - 138 - 139 - def test_non_store_shell_closure_is_rejected(): 140 - raw = _valid_raw() 141 - raw["shellClosure"] = "/tmp/store-paths" 142 - 143 - with pytest.raises(ManifestError, match="shellClosure.*under /nix/store"): 144 - build_manifest_from_raw(raw) 145 - 146 - 147 - def test_shell_closure_without_store_paths_suffix_is_rejected(): 148 - raw = _valid_raw() 149 - raw["shellClosure"] = "/nix/store/shell-closure/paths" 150 - 151 - with pytest.raises(ManifestError, match="shellClosure.*store-paths"): 146 + with pytest.raises(ManifestError, match=expected_match): 152 147 build_manifest_from_raw(raw) 153 148 154 149 ··· 339 334 build_manifest_from_raw(raw) 340 335 341 336 342 - def test_non_object_params_is_rejected(): 343 - raw = _valid_raw() 344 - raw["capabilities"]["echo"]["params"] = "bad" 345 - 346 - with pytest.raises(ManifestError, match="params.*object"): 347 - build_manifest_from_raw(raw) 348 - 349 - 350 - def test_non_object_param_body_is_rejected(): 337 + @pytest.mark.parametrize( 338 + ("field_path", "bad_value", "expected_match"), 339 + [ 340 + (("echo", "params"), "bad", "params.*object"), 341 + (("echo", "params", "message"), "bad", "params.message"), 342 + (("echo", "params", "message", "required"), "yes", "required.*valid boolean"), 343 + (("echo", "params", "message", "description"), ["bad"], "description.*valid string"), 344 + (("echo", "params", "message", "enum"), "red", "enum.*valid list"), 345 + ], 346 + ) 347 + def test_param_shape_validation(field_path, bad_value, expected_match): 351 348 raw = _valid_raw() 352 - raw["capabilities"]["echo"]["params"]["message"] = "bad" 349 + target = raw["capabilities"] 350 + for key in field_path[:-1]: 351 + target = target[key] 352 + target[field_path[-1]] = bad_value 353 353 354 - with pytest.raises(ManifestError, match="params.message"): 355 - build_manifest_from_raw(raw) 356 - 357 - 358 - def test_non_boolean_param_required_is_rejected(): 359 - raw = _valid_raw() 360 - raw["capabilities"]["echo"]["params"]["message"]["required"] = "yes" 361 - 362 - with pytest.raises(ManifestError, match="required.*valid boolean"): 363 - build_manifest_from_raw(raw) 364 - 365 - 366 - def test_non_string_param_description_is_rejected(): 367 - raw = _valid_raw() 368 - raw["capabilities"]["echo"]["params"]["message"]["description"] = ["bad"] 369 - 370 - with pytest.raises(ManifestError, match="description.*valid string"): 371 - build_manifest_from_raw(raw) 372 - 373 - 374 - def test_non_list_param_enum_is_rejected(): 375 - raw = _valid_raw() 376 - raw["capabilities"]["echo"]["params"]["message"]["enum"] = "red" 377 - 378 - with pytest.raises(ManifestError, match="enum.*valid list"): 354 + with pytest.raises(ManifestError, match=expected_match): 379 355 build_manifest_from_raw(raw) 380 356 381 357
+4 -37
tests/test_network_proxy.py
··· 1 1 import http.client 2 - import threading 3 - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 4 2 5 3 from tartarus.network_proxy import FilteringProxy 6 - 7 - 8 - class _HelloHandler(BaseHTTPRequestHandler): 9 - protocol_version = "HTTP/1.0" 10 - 11 - def do_GET(self): 12 - body = b"hello" 13 - self.send_response(200) 14 - self.send_header("Content-Length", str(len(body))) 15 - self.send_header("Connection", "close") 16 - self.end_headers() 17 - self.wfile.write(body) 18 - 19 - def log_message(self, format: str, *args) -> None: 20 - pass 4 + from tests.helpers import HttpServer 21 5 22 6 23 7 def test_filtering_proxy_allows_listed_host(): 24 - with _HttpServer() as upstream: 8 + with HttpServer() as upstream: 25 9 upstream_host, upstream_port = upstream.server_address 26 10 27 11 with FilteringProxy([f"{upstream_host}:{upstream_port}"]) as proxy: ··· 39 23 40 24 41 25 def test_filtering_proxy_denies_unlisted_host(): 42 - with _HttpServer() as upstream: 26 + with HttpServer() as upstream: 43 27 upstream_host, upstream_port = upstream.server_address 44 28 45 29 with FilteringProxy(["example.com:80"]) as proxy: ··· 56 40 57 41 58 42 def test_filtering_proxy_wildcard_allows_any_host(): 59 - with _HttpServer() as upstream: 43 + with HttpServer() as upstream: 60 44 upstream_host, upstream_port = upstream.server_address 61 45 62 46 with FilteringProxy(["*"]) as proxy: ··· 71 55 f"proxy decisions: 1 allowed, 0 blocked " 72 56 f"(GET {upstream_host}:{upstream_port} allowed)" 73 57 ) 74 - 75 - 76 - class _HttpServer: 77 - def __enter__(self): 78 - self._server = ThreadingHTTPServer(("127.0.0.1", 0), _HelloHandler) 79 - self._thread = threading.Thread( 80 - target=self._server.serve_forever, 81 - name="tartarus-nix-test-http-server", 82 - daemon=True, 83 - ) 84 - self._thread.start() 85 - return self._server 86 - 87 - def __exit__(self, *_args): 88 - self._server.shutdown() 89 - self._server.server_close() 90 - self._thread.join(timeout=5) 91 58 92 59 93 60 def _proxy_address(proxy_url: str) -> tuple[str, int]:
+22
tests/test_process.py
··· 1 + import pytest 2 + 3 + from tartarus.process import ProcessError, run_checked 4 + 5 + 6 + def test_run_checked_returns_stdout(): 7 + assert run_checked(["echo", "-n", "hello"]) == "hello" 8 + 9 + 10 + def test_run_checked_raises_on_empty_command(): 11 + with pytest.raises(ProcessError, match="empty"): 12 + run_checked([]) 13 + 14 + 15 + def test_run_checked_raises_on_missing_binary(): 16 + with pytest.raises(ProcessError, match="not found"): 17 + run_checked(["nonexistent_command_xyz"]) 18 + 19 + 20 + def test_run_checked_raises_on_nonzero_exit(): 21 + with pytest.raises(ProcessError, match="failed"): 22 + run_checked(["sh", "-c", "exit 1"])
+141 -65
tests/test_provider.py
··· 1 1 import asyncio 2 2 3 + from typing import Any 4 + 3 5 import pytest 4 6 5 7 from tartarus.models import TextDelta, ToolResult, TurnComplete ··· 7 9 from tests.manifest_fixtures import echo_manifest 8 10 9 11 10 - def _provider(): 11 - return OpenAICompatProvider( 12 + def _provider(**overrides: Any): 13 + kwargs: dict[str, Any] = dict( 12 14 base_url="https://example.test/v1", 13 15 api_key="secret", 14 16 model="opencode/gpt-5.5", 15 17 max_tokens=128, 16 18 ) 19 + kwargs.update(overrides) 20 + return OpenAICompatProvider(**kwargs) 21 + 22 + 23 + # -- shared fake HTTP stack --------------------------------------------------- 24 + 25 + 26 + class _FakeTransport: 27 + """Replay supplied SSE lines as a streaming response.""" 28 + 29 + def __init__(self, lines: list[str], status_code: int = 200): 30 + self._lines = lines 31 + self.status_code = status_code 32 + 33 + async def aiter_lines(self): 34 + for line in self._lines: 35 + yield line 36 + 37 + async def aread(self): 38 + return b"error body" 39 + 40 + 41 + _stored_stream: _FakeTransport = _FakeTransport([]) 42 + 43 + 44 + class _FakeClient: 45 + def __init__(self, *args, **kwargs): 46 + pass 47 + 48 + async def __aenter__(self): 49 + return self 50 + 51 + async def __aexit__(self, *exc): 52 + return False 53 + 54 + def stream(self, *args, **kwargs): 55 + return _make_stream(_stored_stream) 56 + 57 + 58 + def _make_stream(transport): 59 + class _Stream: 60 + def __init__(self): 61 + pass 62 + 63 + async def __aenter__(self): 64 + return transport 65 + 66 + async def __aexit__(self, *exc): 67 + return False 68 + 69 + return _Stream() 70 + 71 + 72 + def _configure_fake_stream(lines, status_code=200): 73 + global _stored_stream 74 + _stored_stream = _FakeTransport(lines, status_code) 17 75 18 76 19 77 def test_adapt_tools_wraps_in_function_envelope(): ··· 25 83 26 84 27 85 def test_build_body_includes_sampling_when_set(): 28 - provider = OpenAICompatProvider( 29 - base_url="https://example.test/v1", 30 - api_key="secret", 31 - model="opencode/gpt-5.5", 32 - max_tokens=128, 33 - sampling={"temperature": 0, "top_p": 0.9}, 34 - ) 86 + provider = _provider(sampling={"temperature": 0, "top_p": 0.9}) 35 87 36 88 body = provider._build_body("sys", [], []) 37 89 ··· 47 99 48 100 49 101 def test_build_body_sampling_cannot_override_reserved_fields(): 50 - provider = OpenAICompatProvider( 51 - base_url="https://example.test/v1", 52 - api_key="secret", 53 - model="opencode/gpt-5.5", 54 - max_tokens=128, 102 + provider = _provider( 55 103 sampling={"model": "other", "max_tokens": 999, "messages": []}, 56 104 ) 57 105 ··· 212 260 "data: [DONE]", 213 261 ] 214 262 215 - class FakeResponse: 216 - status_code = 200 217 - 218 - async def aiter_lines(self): 219 - for line in chunks: 220 - yield line 221 - 222 - async def aread(self): # pragma: no cover - only used on error paths 223 - return b"" 224 - 225 - class FakeStream: 226 - async def __aenter__(self): 227 - return FakeResponse() 228 - 229 - async def __aexit__(self, *exc): 230 - return False 231 - 232 - class FakeClient: 233 - def __init__(self, *args, **kwargs): 234 - pass 235 - 236 - async def __aenter__(self): 237 - return self 238 - 239 - async def __aexit__(self, *exc): 240 - return False 241 - 242 - def stream(self, *args, **kwargs): 243 - return FakeStream() 244 - 245 - monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", FakeClient) 263 + _configure_fake_stream(chunks) 264 + monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) 246 265 247 266 async def collect(): 248 267 return [e async for e in provider.stream("sys", [], [])] ··· 266 285 """Invalid JSON or non-object SSE payloads abort the stream with ProviderError.""" 267 286 provider = _provider() 268 287 269 - class FakeResponse: 270 - status_code = 200 288 + _configure_fake_stream([bad_line]) 289 + monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) 290 + 291 + async def collect(): 292 + return [e async for e in provider.stream("sys", [], [])] 293 + 294 + with pytest.raises(ProviderError, match=expected_msg): 295 + asyncio.run(collect()) 296 + 297 + 298 + def test_stream_raises_on_http_error(monkeypatch): 299 + """A non-200 response body aborts the stream with ProviderError.""" 300 + provider = _provider() 301 + 302 + _configure_fake_stream([], status_code=500) 303 + monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) 304 + 305 + async def collect(): 306 + return [e async for e in provider.stream("sys", [], [])] 307 + 308 + with pytest.raises(ProviderError, match="backend returned HTTP 500"): 309 + asyncio.run(collect()) 310 + 311 + 312 + def test_stream_passes_tool_call_deltas(monkeypatch): 313 + """SSE chunks with tool_calls deltas are accumulated into the final turn.""" 314 + provider = _provider() 315 + 316 + chunks = [ 317 + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"echo"}}]}}]}', # noqa: E501 318 + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"mess"}}]}}]}', # noqa: E501 319 + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"age\\": \\"hi\\"}"}}]}}]}', # noqa: E501 320 + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}', 321 + "data: [DONE]", 322 + ] 323 + 324 + _configure_fake_stream(chunks) 325 + monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", _FakeClient) 326 + 327 + async def collect(): 328 + return [e async for e in provider.stream("sys", [], [])] 329 + 330 + events = asyncio.run(collect()) 331 + 332 + assert isinstance(events[-1], TurnComplete) 333 + turn = events[-1].turn 334 + assert turn.stop_reason == "tool_calls" 335 + assert len(turn.tool_calls) == 1 336 + assert turn.tool_calls[0].name == "echo" 337 + assert turn.tool_calls[0].arguments == {"message": "hi"} 271 338 272 - async def aiter_lines(self): 273 - yield bad_line 274 339 275 - async def aread(self): # pragma: no cover - only used on error paths 276 - return b"" 340 + def test_complete_round_trips(monkeypatch): 341 + """The non-streaming complete() method parses a response into an AssistantTurn.""" 342 + provider = _provider() 277 343 278 - class FakeStream: 279 - async def __aenter__(self): 280 - return FakeResponse() 344 + response_json = { 345 + "choices": [ 346 + { 347 + "finish_reason": "stop", 348 + "message": {"content": "Hello, world"}, 349 + } 350 + ] 351 + } 352 + 353 + class _FakePostResponse: 354 + status_code = 200 355 + text = "ok" 281 356 282 - async def __aexit__(self, *exc): 283 - return False 357 + def json(self): 358 + return response_json 284 359 285 - class FakeClient: 360 + class _FakePostClient: 286 361 def __init__(self, *args, **kwargs): 287 362 pass 288 363 ··· 292 367 async def __aexit__(self, *exc): 293 368 return False 294 369 295 - def stream(self, *args, **kwargs): 296 - return FakeStream() 370 + async def post(self, *args, **kwargs): 371 + return _FakePostResponse() 297 372 298 - monkeypatch.setattr("tartarus.provider.openai_compat.httpx.AsyncClient", FakeClient) 299 - 300 - async def collect(): 301 - return [e async for e in provider.stream("sys", [], [])] 373 + monkeypatch.setattr( 374 + "tartarus.provider.openai_compat.httpx.AsyncClient", _FakePostClient 375 + ) 302 376 303 - with pytest.raises(ProviderError, match=expected_msg): 304 - asyncio.run(collect()) 377 + turn = asyncio.run(provider.complete("sys", [], [])) 378 + assert turn.text == "Hello, world" 379 + assert turn.stop_reason == "end" 380 + assert turn.tool_calls == []
+15
tests/test_session.py
··· 130 130 assert ( 131 131 SessionStore(str(tmp_path), "s1").first_user_message() == "summarize the repo" 132 132 ) 133 + 134 + 135 + def test_first_user_message_returns_none_for_assistant_only(tmp_path): 136 + store = SessionStore(str(tmp_path), "s1") 137 + store.append([{"role": "assistant", "content": "hello"}]) 138 + 139 + assert SessionStore(str(tmp_path), "s1").first_user_message() is None 140 + 141 + 142 + def test_list_ids_ignores_non_jsonl_files(tmp_path): 143 + for name in ("a.jsonl", "b.txt", "c.log"): 144 + (tmp_path / name).write_text("{}") 145 + 146 + ids = SessionStore.list_ids(str(tmp_path)) 147 + assert ids == ["a"]
+37
tests/test_shell.py
··· 1 + import pytest 2 + 3 + from tartarus.process import ProcessError 4 + from tartarus.shell import ShellError, resolve_minimal_shell_path 5 + 6 + 7 + def test_resolve_minimal_shell_path_propagates_build_failure(monkeypatch): 8 + def fail(_command): 9 + raise ProcessError("cannot build `nixpkgs#coreutils`: nix not found") 10 + 11 + monkeypatch.setattr("tartarus.shell.run_checked", fail) 12 + 13 + with pytest.raises(ShellError, match="cannot build"): 14 + resolve_minimal_shell_path() 15 + 16 + 17 + def test_resolve_minimal_shell_path_rejects_empty_output(monkeypatch): 18 + def empty(_command): 19 + return "" 20 + 21 + monkeypatch.setattr("tartarus.shell.run_checked", empty) 22 + 23 + with pytest.raises(ShellError, match="produced no store path"): 24 + resolve_minimal_shell_path() 25 + 26 + 27 + def test_resolve_minimal_shell_path_rejects_no_bin_directory(monkeypatch, tmp_path): 28 + no_bin = tmp_path / "no-bin" 29 + no_bin.mkdir() 30 + 31 + def fake_build(_command): 32 + return f"{no_bin}\n" 33 + 34 + monkeypatch.setattr("tartarus.shell.run_checked", fake_build) 35 + 36 + with pytest.raises(ShellError, match="no output of"): 37 + resolve_minimal_shell_path()