import asyncio import json import httpx import pytest from langchain_core.messages import ToolMessage from langchain_core.utils.function_calling import convert_to_openai_tool from pydantic import ValidationError from agentsouk_langchain import AgentSoukToolkit KEY = "as_test_offlinefixture" JOB = "job_01M1YZ049GKYWJYZ2NQF8G670T" LISTING = "lst_01M1YYZ0RRHS8PTBA662W5E8CX" def kit(handler, **kwargs): toolkit = AgentSoukToolkit(KEY, transport=httpx.MockTransport(handler), **kwargs) return toolkit, {t.name: t for t in toolkit.get_tools()} def test_read_only_default_blocks_every_mutation_before_http(): calls = [] toolkit, tools = kit(lambda req: calls.append(req)) with toolkit: for name, args in [ ("souk_create_job", {"listing_id": LISTING, "input": {}}), ("souk_accept", {"job_id": JOB}), ("souk_deliver", {"job_id": JOB, "output": {"ok": True}}), ]: result = tools[name].invoke({**args, "idempotency_key": "unit-test-action"}) assert result["error"]["code"] == "mutations_disabled" assert calls == [] def test_credentials_config_only_and_live_requires_deliberate_opt_in(monkeypatch): monkeypatch.delenv("AGENTSOUK_API_KEY", raising=False) with pytest.raises(ValueError): AgentSoukToolkit() with pytest.raises(ValueError, match="allow_live"): AgentSoukToolkit("as_live_example") with AgentSoukToolkit("as_live_example", allow_live=True) as toolkit: for tool in toolkit.get_tools(): schema = convert_to_openai_tool(tool) assert KEY not in json.dumps(schema) fields = schema["function"]["parameters"]["properties"] assert not {"api_key", "allow_live", "allow_mutations", "base_url"} & fields.keys() def test_env_cannot_redirect_credentials_and_native_query_is_encoded(monkeypatch): monkeypatch.setenv("AGENTSOUK_BASE_URL", "https://untrusted.invalid") seen = [] def handler(req): seen.append(req) return httpx.Response(200, json={"data": [], "next_cursor": "next"}) toolkit, tools = kit(handler) with toolkit: result = tools["souk_search"].invoke({"query": "a & b", "limit": 3}) assert result["next_cursor"] == "next" assert seen[0].url.host == "api.agentsouk.dev" assert seen[0].url.params["q"] == "a & b" assert seen[0].headers["authorization"] == f"Bearer {KEY}" @pytest.mark.parametrize("args", [ {"job_id": "../agents/me"}, {"job_id": JOB, "api_key": "as_live_injected"}, {"job_id": "job_abc?other=1"}, ]) def test_tool_schema_rejects_path_and_config_injection(args): toolkit, tools = kit(lambda req: pytest.fail("HTTP must not run")) with toolkit, pytest.raises(ValidationError): tools["souk_get_job"].invoke(args) def test_create_preserves_arbitrary_json_and_native_idempotency(): seen = [] def handler(req): seen.append(req) return httpx.Response(201, json={"id": JOB, "status": "open"}) toolkit, tools = kit(handler, allow_mutations=True) args = {"listing_id": LISTING, "input": {"nested": ["é", {"n": 2}]}, "title": "Offline fixture", "units": 2, "max_revisions": 0, "idempotency_key": "persist-create-001"} with toolkit: assert tools["souk_create_job"].invoke(args)["id"] == JOB tools["souk_create_job"].invoke(args) assert len(seen) == 2 assert seen[0].content == seen[1].content assert seen[0].headers["idempotency-key"] == seen[1].headers["idempotency-key"] == "persist-create-001" body = json.loads(seen[0].content) assert body == {k: v for k, v in args.items() if k != "idempotency_key"} def test_seller_delivery_buyer_accept_and_receipt_through_real_tools(): seen = [] receipt = {"receipt": {"job_id": JOB, "env": "test", "status": "completed"}, "signature": {"alg": "Ed25519", "value": "offline_fixture_only"}} def handler(req): seen.append(req) if req.url.path.endswith("/receipt"): return httpx.Response(200, json=receipt) return httpx.Response(200, json={"id": JOB, "status": "in_progress"}) toolkit, tools = kit(handler, allow_mutations=True) with toolkit: tools["souk_accept"].invoke({"job_id": JOB, "idempotency_key": "seller-accept-001"}) tools["souk_deliver"].invoke({"job_id": JOB, "idempotency_key": "seller-deliver-001", "output": {"summary": "result"}, "preview": {"size": 1}}) tools["souk_accept"].invoke({"job_id": JOB, "idempotency_key": "buyer-accept-001"}) assert tools["souk_receipt"].invoke({"job_id": JOB}) == receipt assert [r.url.path for r in seen] == [f"/v1/jobs/{JOB}/{a}" for a in ["accept", "deliver", "accept", "receipt"]] assert [r.headers.get("idempotency-key") for r in seen[:3]] == ["seller-accept-001", "seller-deliver-001", "buyer-accept-001"] assert json.loads(seen[1].content) == {"output": {"summary": "result"}, "preview": {"size": 1}} @pytest.mark.parametrize("status,body", [ (402, {"error": {"code": "payment_required"}, "amount": 5, "network": "eip155:84532"}), (409, {"error": {"code": "invalid_state"}}), (200, {"id": JOB, "status": "completed"}), ]) def test_payment_probe_preserves_402_and_conflict_without_payment(status, body): seen = [] def handler(req): seen.append(req) return httpx.Response(status, json=body) toolkit, tools = kit(handler) with toolkit: assert tools["souk_pay_terms"].invoke({"job_id": JOB}) == {"http_status": status, "body": body} assert seen[0].content == b"" assert seen[0].url.path == f"/v1/jobs/{JOB}/pay" assert "transaction" not in seen[0].headers def test_no_retries_and_no_secret_echo_in_error_or_tool_message(): seen = [] def handler(req): seen.append(req) return httpx.Response(503, json={"error": {"code": "unavailable", "hint": "Never echo " + KEY, "request_id": "request1"}}) toolkit, tools = kit(handler, allow_mutations=True) with toolkit: result = tools["souk_accept"].invoke({"type": "tool_call", "name": "souk_accept", "id": "call1", "args": {"job_id": JOB, "idempotency_key": "accept-once-001"}}) assert isinstance(result, ToolMessage) assert KEY not in result.content assert "[REDACTED]" in result.content assert len(seen) == 1 def test_untrusted_success_content_does_not_return_credentials(): toolkit, tools = kit(lambda req: httpx.Response(200, json={"api_keys": {"test": KEY}, "nested": [{"secret_key": "secret", "description": KEY}], KEY: "unsafe key"})) with toolkit: result = tools["souk_get_job"].invoke({"job_id": JOB}) assert KEY not in json.dumps(result) assert result["nested"][0]["secret_key"] == "[REDACTED]" def test_missing_idempotency_key_and_invalid_units_never_send(): toolkit, tools = kit(lambda req: pytest.fail("HTTP must not run"), allow_mutations=True) with toolkit: with pytest.raises(ValidationError): tools["souk_accept"].invoke({"job_id": JOB}) with pytest.raises(ValidationError): tools["souk_create_job"].invoke({"listing_id": LISTING, "input": {}, "units": True, "idempotency_key": "create-key-001"}) def test_preview_limit_uses_utf8_bytes_before_sending(): toolkit, tools = kit(lambda req: pytest.fail("HTTP must not run"), allow_mutations=True) with toolkit: result = tools["souk_deliver"].invoke({"job_id": JOB, "output": {}, "preview": "é" * 2100, "idempotency_key": "delivery-key-001"}) assert result["error"]["code"] == "payload_too_large" def test_transport_timeout_is_one_call_and_marks_uncertainty(): seen = [] def handler(req): seen.append(req) raise httpx.ReadTimeout("private upstream request data", request=req) toolkit, tools = kit(handler, allow_mutations=True) with toolkit: result = tools["souk_accept"].invoke({"job_id": JOB, "idempotency_key": "accept-key-001"}) assert len(seen) == 1 assert result["error"]["code"] == "transport_error" assert "unknown" in result["error"]["hint"] assert "private" not in json.dumps(result) def test_async_tool_invocation_works_with_sync_sdk(): seen = [] def handler(req): seen.append(req) return httpx.Response(200, json={"id": JOB, "output": {"ok": True}}) toolkit, tools = kit(handler) with toolkit: result = asyncio.run(tools["souk_get_job"].ainvoke({"job_id": JOB})) assert result["output"] == {"ok": True} assert len(seen) == 1