mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add Python parity for HttpRequestAction in declarative workflow (#5599)
* Add Python parity for HttpRequestAction in declarative workflow * Ran pyupgrade and pright to fix CI issues * Fix conversation ID dot parsing for http executor * Removed unnecessary export command
This commit is contained in:
committed by
GitHub
Unverified
parent
18293ffb31
commit
bc42874690
@@ -0,0 +1,329 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for ``DefaultHttpRequestHandler``.
|
||||
|
||||
These tests exercise the real handler against ``httpx.MockTransport`` (no real
|
||||
network) to cover the parts of the handler not exercisable through the executor
|
||||
stub: query-param URL composition, content-type forwarding, per-request
|
||||
timeout overrides, multi-value response header preservation, and client
|
||||
ownership semantics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
# These tests don't actually need PowerFx, but the rest of the suite gates on
|
||||
# Python versions and we keep behaviour consistent.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.version_info >= (3, 14),
|
||||
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
|
||||
)
|
||||
|
||||
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
|
||||
DefaultHttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
)
|
||||
|
||||
|
||||
def _make_handler(transport: httpx.MockTransport) -> DefaultHttpRequestHandler:
|
||||
"""Return a handler with a MockTransport-backed caller-owned client."""
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
return DefaultHttpRequestHandler(client=client)
|
||||
|
||||
|
||||
class TestRequestComposition:
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_parameters_merged_into_url(self) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = request
|
||||
return httpx.Response(200, text="ok")
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="GET",
|
||||
url="https://api.example.test/items",
|
||||
query_parameters={"q": "alpha", "limit": "5"},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
req = captured["req"]
|
||||
# httpx exposes the merged URL with QS appended
|
||||
assert req.url.params.get("q") == "alpha"
|
||||
assert req.url.params.get("limit") == "5"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_body_content_type_forwarded(self) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = request
|
||||
return httpx.Response(204)
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="POST",
|
||||
url="https://api.example.test/items",
|
||||
body='{"k":"v"}',
|
||||
body_content_type="application/json",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
req = captured["req"]
|
||||
assert req.headers.get("content-type") == "application/json"
|
||||
assert req.content == b'{"k":"v"}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_content_type_header_not_overwritten(self) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = request
|
||||
return httpx.Response(200, text="ok")
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="POST",
|
||||
url="https://api.example.test/items",
|
||||
headers={"Content-Type": "application/xml"}, # caller wins
|
||||
body="<x/>",
|
||||
body_content_type="application/json",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
req = captured["req"]
|
||||
assert req.headers.get("content-type") == "application/xml"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_body_without_content_type_defaults_to_text_plain(self) -> None:
|
||||
"""Match .NET DefaultHttpRequestHandler: body without explicit content type → ``text/plain``."""
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = request
|
||||
return httpx.Response(204)
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="POST",
|
||||
url="https://api.example.test/items",
|
||||
body="hello",
|
||||
# No body_content_type and no Content-Type header.
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
req = captured["req"]
|
||||
assert req.headers.get("content-type") == "text/plain"
|
||||
assert req.content == b"hello"
|
||||
|
||||
|
||||
class TestTimeout:
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_request_timeout_surfaces_as_timeout_exception(self) -> None:
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.TimeoutException("simulated timeout", request=request)
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
with pytest.raises(httpx.TimeoutException):
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="GET",
|
||||
url="https://api.example.test/slow",
|
||||
timeout_ms=50,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
|
||||
class TestResponseHeaders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_value_headers_preserved(self) -> None:
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
text="ok",
|
||||
headers=[
|
||||
("Content-Type", "application/json"),
|
||||
("Set-Cookie", "a=1"),
|
||||
("Set-Cookie", "b=2"),
|
||||
],
|
||||
)
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
assert result.is_success_status_code
|
||||
# The handler keeps multi-value headers as list[str].
|
||||
assert result.headers.get("set-cookie") == ["a=1", "b=2"]
|
||||
assert result.headers.get("content-type") == ["application/json"]
|
||||
|
||||
|
||||
class TestClientOwnership:
|
||||
@pytest.mark.asyncio
|
||||
async def test_owned_client_is_closed_on_aclose(self) -> None:
|
||||
handler = DefaultHttpRequestHandler()
|
||||
# Inject a MockTransport-backed client into the owned slot and verify
|
||||
# aclose() releases it. Avoids real network access.
|
||||
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
|
||||
handler._owned_client = owned
|
||||
assert not owned.is_closed
|
||||
await handler.aclose()
|
||||
assert owned.is_closed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caller_owned_client_is_not_closed(self) -> None:
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
|
||||
handler = DefaultHttpRequestHandler(client=client)
|
||||
await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
|
||||
await handler.aclose()
|
||||
assert not client.is_closed
|
||||
await client.aclose() # cleanup
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_first_send_creates_single_owned_client(self) -> None:
|
||||
"""Concurrent first-send calls must not race-leak duplicate clients.
|
||||
|
||||
Without the lock, two concurrent calls on a fresh handler would each
|
||||
observe ``_owned_client is None`` and create their own
|
||||
``httpx.AsyncClient``, orphaning one. Verify that lazy initialization
|
||||
is serialized: all concurrent sends end up using the same client and
|
||||
``aclose()`` cleanly closes it.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Patch httpx.AsyncClient to count constructions, but only when called
|
||||
# from inside _resolve_client (no transport=) so we don't break the
|
||||
# MockTransport-backed clients used elsewhere.
|
||||
original_ctor = httpx.AsyncClient
|
||||
construction_count = 0
|
||||
|
||||
def counting_ctor(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
nonlocal construction_count
|
||||
if not args and not kwargs:
|
||||
construction_count += 1
|
||||
return original_ctor(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
|
||||
return original_ctor(*args, **kwargs)
|
||||
|
||||
import agent_framework_declarative._workflows._http_handler as hh
|
||||
|
||||
hh.httpx.AsyncClient = counting_ctor # type: ignore[assignment]
|
||||
try:
|
||||
handler = DefaultHttpRequestHandler()
|
||||
try:
|
||||
await asyncio.gather(*[
|
||||
handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x")) for _ in range(8)
|
||||
])
|
||||
finally:
|
||||
await handler.aclose()
|
||||
finally:
|
||||
hh.httpx.AsyncClient = original_ctor # type: ignore[assignment]
|
||||
|
||||
assert construction_count == 1, (
|
||||
f"Expected exactly 1 owned client to be lazily created but got {construction_count}"
|
||||
)
|
||||
|
||||
|
||||
class TestClientProvider:
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_provider_overrides_default(self) -> None:
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def primary(request: httpx.Request) -> httpx.Response:
|
||||
captured["transport"] = "primary"
|
||||
return httpx.Response(200, text="primary")
|
||||
|
||||
def provided(request: httpx.Request) -> httpx.Response:
|
||||
captured["transport"] = "provided"
|
||||
return httpx.Response(200, text="provided")
|
||||
|
||||
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
|
||||
provided_client = httpx.AsyncClient(transport=httpx.MockTransport(provided))
|
||||
|
||||
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient:
|
||||
return provided_client
|
||||
|
||||
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
|
||||
try:
|
||||
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
|
||||
assert result.body == "provided"
|
||||
assert captured["transport"] == "provided"
|
||||
finally:
|
||||
await handler.aclose()
|
||||
await primary_client.aclose()
|
||||
await provided_client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_provider_returning_none_falls_back(self) -> None:
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def primary(request: httpx.Request) -> httpx.Response:
|
||||
captured["transport"] = "primary"
|
||||
return httpx.Response(200, text="primary")
|
||||
|
||||
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient | None:
|
||||
return None
|
||||
|
||||
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
|
||||
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
|
||||
try:
|
||||
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
|
||||
assert result.body == "primary"
|
||||
finally:
|
||||
await handler.aclose()
|
||||
await primary_client.aclose()
|
||||
|
||||
|
||||
class TestValidation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_url_raises(self) -> None:
|
||||
handler = DefaultHttpRequestHandler()
|
||||
with pytest.raises(ValueError):
|
||||
await handler.send(HttpRequestInfo(method="GET", url=""))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_method_raises(self) -> None:
|
||||
handler = DefaultHttpRequestHandler()
|
||||
with pytest.raises(ValueError):
|
||||
await handler.send(HttpRequestInfo(method="", url="https://x.test/"))
|
||||
|
||||
|
||||
class TestAsyncContextManager:
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_closes_owned_client(self) -> None:
|
||||
async with DefaultHttpRequestHandler() as handler:
|
||||
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
|
||||
handler._owned_client = owned
|
||||
assert owned.is_closed
|
||||
@@ -0,0 +1,645 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for HttpRequestActionExecutor.
|
||||
|
||||
These tests use a stub HttpRequestHandler that returns canned HttpRequestResults.
|
||||
No real network or httpx transports are exercised. See
|
||||
test_default_http_request_handler.py for tests that exercise the real
|
||||
DefaultHttpRequestHandler against httpx.MockTransport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _powerfx_available or sys.version_info >= (3, 14),
|
||||
reason="PowerFx engine not available (requires dotnet runtime)",
|
||||
)
|
||||
|
||||
from agent_framework_declarative._workflows import ( # noqa: E402
|
||||
DECLARATIVE_STATE_KEY,
|
||||
DeclarativeActionError,
|
||||
DeclarativeWorkflowError,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
WorkflowFactory,
|
||||
)
|
||||
|
||||
|
||||
class StubHandler:
|
||||
"""Test stub that records the last call and returns a canned result."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
result: HttpRequestResult | None = None,
|
||||
*,
|
||||
raise_exc: BaseException | None = None,
|
||||
) -> None:
|
||||
self.result = result
|
||||
self.raise_exc = raise_exc
|
||||
self.last_info: HttpRequestInfo | None = None
|
||||
self.call_count = 0
|
||||
|
||||
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
|
||||
self.call_count += 1
|
||||
self.last_info = info
|
||||
if self.raise_exc is not None:
|
||||
raise self.raise_exc
|
||||
assert self.result is not None
|
||||
return self.result
|
||||
|
||||
|
||||
def _ok(body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
|
||||
return HttpRequestResult(
|
||||
status_code=200,
|
||||
is_success_status_code=True,
|
||||
body=body,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
|
||||
def _err(status: int = 500, body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
|
||||
return HttpRequestResult(
|
||||
status_code=status,
|
||||
is_success_status_code=False,
|
||||
body=body,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
|
||||
async def _run(yaml_def: dict[str, Any], handler: HttpRequestHandler) -> Any:
|
||||
"""Build & run a workflow, returning final WorkflowState."""
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(yaml_def)
|
||||
return await workflow.run({})
|
||||
|
||||
|
||||
def _state(workflow: Any, events: Any) -> dict[str, Any]:
|
||||
"""Read declarative state out of the workflow after run completes."""
|
||||
return workflow._state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
|
||||
|
||||
# Helper used by parametrised path tests
|
||||
_TEST_URL = "https://api.example.test/items"
|
||||
|
||||
|
||||
def _action(
|
||||
*,
|
||||
method: str | None = None,
|
||||
url: str = _TEST_URL,
|
||||
headers: dict[str, Any] | None = None,
|
||||
query_parameters: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
response: Any = None,
|
||||
response_headers: Any = None,
|
||||
conversation_id: str | None = None,
|
||||
request_timeout_ms: int | None = None,
|
||||
connection: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
action: dict[str, Any] = {
|
||||
"kind": "HttpRequestAction",
|
||||
"id": "http_action",
|
||||
"url": url,
|
||||
}
|
||||
if method is not None:
|
||||
action["method"] = method
|
||||
if headers is not None:
|
||||
action["headers"] = headers
|
||||
if query_parameters is not None:
|
||||
action["queryParameters"] = query_parameters
|
||||
if body is not None:
|
||||
action["body"] = body
|
||||
if response is not None:
|
||||
action["response"] = response
|
||||
if response_headers is not None:
|
||||
action["responseHeaders"] = response_headers
|
||||
if conversation_id is not None:
|
||||
action["conversationId"] = conversation_id
|
||||
if request_timeout_ms is not None:
|
||||
action["requestTimeoutInMilliseconds"] = request_timeout_ms
|
||||
if connection is not None:
|
||||
action["connection"] = connection
|
||||
return action
|
||||
|
||||
|
||||
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"name": "http_test", "actions": [action]}
|
||||
|
||||
|
||||
# ---------- Success path: response parsing ----------------------------------
|
||||
|
||||
|
||||
class TestSuccessPath:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_parses_json_object(self) -> None:
|
||||
handler = StubHandler(_ok('{"key":"value","number":42}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(method="GET", response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == {"key": "value", "number": 42}
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.method == "GET"
|
||||
assert handler.last_info.url == _TEST_URL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_parses_plain_string(self) -> None:
|
||||
handler = StubHandler(_ok("not-json content"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "not-json content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_empty_body_yields_none(self) -> None:
|
||||
handler = StubHandler(_ok(""))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_object_form_path(self) -> None:
|
||||
handler = StubHandler(_ok('{"x":1}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response={"path": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == {"x": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_response_path_does_not_assign(self) -> None:
|
||||
handler = StubHandler(_ok('{"x":1}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
# Should complete without error and without writing anything
|
||||
await workflow.run({})
|
||||
|
||||
|
||||
# ---------- Method / headers / query params --------------------------------
|
||||
|
||||
|
||||
class TestRequestComposition:
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_method_is_get(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
await workflow.run({})
|
||||
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.method == "GET"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_method_uppercased(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(method="post")))
|
||||
await workflow.run({})
|
||||
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.method == "POST"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headers_are_forwarded_and_empty_skipped(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"X-Empty": "",
|
||||
"Authorization": "Bearer token",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.headers == {
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer token",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_parameters_stringified(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
query_parameters={
|
||||
"name": "alpha",
|
||||
"limit": 10,
|
||||
"active": True,
|
||||
"ratio": 0.5,
|
||||
"missing": None, # dropped
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.query_parameters == {
|
||||
"name": "alpha",
|
||||
"limit": "10",
|
||||
"active": "true",
|
||||
"ratio": "0.5",
|
||||
}
|
||||
|
||||
|
||||
# ---------- Body composition ------------------------------------------------
|
||||
|
||||
|
||||
class TestBody:
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_json_body_sets_content_type_and_serialises(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
method="POST",
|
||||
body={"kind": "json", "content": {"k": "v", "n": 1}},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body_content_type == "application/json"
|
||||
assert info.body is not None
|
||||
# JSON serialized, key order may vary
|
||||
import json
|
||||
|
||||
assert json.loads(info.body) == {"k": "v", "n": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_raw_body_uses_declared_content_type(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
method="POST",
|
||||
body={
|
||||
"kind": "raw",
|
||||
"content": "raw body text",
|
||||
"contentType": "text/plain",
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body == "raw body text"
|
||||
assert info.body_content_type == "text/plain"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_raw_body_without_content_type_defaults_to_text_plain(self) -> None:
|
||||
"""Match .NET RawRequestContent: no contentType => default text/plain.
|
||||
|
||||
Otherwise the request is sent without a Content-Type header which most
|
||||
servers will treat as application/octet-stream and fail to parse.
|
||||
"""
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
method="POST",
|
||||
body={"kind": "raw", "content": "plain body"},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body == "plain body"
|
||||
assert info.body_content_type == "text/plain"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_form_body_kinds_accepted(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
method="POST",
|
||||
body={"kind": "JsonRequestContent", "content": {"k": 1}},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body_content_type == "application/json"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_body_kind_raises(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(body={"kind": "weirdform", "content": "x"})))
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await workflow.run({})
|
||||
# Should surface as ValueError (potentially wrapped by runner)
|
||||
msg = str(excinfo.value)
|
||||
assert "weirdform" in msg or "unsupported value" in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_body_omitted(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
await workflow.run({})
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body is None
|
||||
assert info.body_content_type is None
|
||||
|
||||
|
||||
# ---------- Non-2xx and error handling -------------------------------------
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_raises_declarative_action_error(self) -> None:
|
||||
handler = StubHandler(_err(status=500, body="server exploded"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "500" in msg
|
||||
assert "server exploded" in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_long_body_truncated(self) -> None:
|
||||
big_body = "A" * 1000
|
||||
handler = StubHandler(_err(status=500, body=big_body))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "[truncated]" in msg
|
||||
assert len(msg) < 512
|
||||
# Should NOT contain the full 1000-char body
|
||||
assert big_body not in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_empty_body_omits_body_section(self) -> None:
|
||||
handler = StubHandler(_err(status=404, body=""))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "404" in msg
|
||||
assert "Body:" not in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_control_chars_collapsed(self) -> None:
|
||||
handler = StubHandler(_err(status=500, body="line1\r\nline2\tlong"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "\r" not in msg
|
||||
assert "\n" not in msg
|
||||
assert "\t" not in msg
|
||||
assert "line1 line2 long" in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_exception_becomes_declarative_action_error(self) -> None:
|
||||
handler = StubHandler(raise_exc=httpx.TimeoutException("timeout"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
assert "timed out" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdlib_timeout_error_becomes_declarative_action_error(self) -> None:
|
||||
handler = StubHandler(raise_exc=TimeoutError("clock"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
assert "timed out" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_error_becomes_declarative_action_error(self) -> None:
|
||||
handler = StubHandler(raise_exc=httpx.ConnectError("dns failure"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "failed" in msg
|
||||
assert _TEST_URL in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_error_propagates_unchanged(self) -> None:
|
||||
"""CancelledError from the handler must propagate so cancellation works."""
|
||||
handler = StubHandler(raise_exc=asyncio.CancelledError())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
# CancelledError is allowed to surface as either CancelledError or as
|
||||
# the runner's wrapped form, but it MUST NOT be DeclarativeActionError.
|
||||
with pytest.raises(BaseException) as excinfo:
|
||||
await workflow.run({})
|
||||
assert not isinstance(excinfo.value, DeclarativeActionError)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_exception_from_custom_handler_wrapped(self) -> None:
|
||||
"""A custom handler raising a non-httpx Exception must be wrapped.
|
||||
|
||||
Authors can plug in custom HttpRequestHandler implementations that use
|
||||
any transport (requests-like clients, gRPC bridges, mock test doubles,
|
||||
etc.). The executor must wrap arbitrary Exception subclasses uniformly
|
||||
so that workflow error handling stays consistent across transports.
|
||||
"""
|
||||
handler = StubHandler(raise_exc=RuntimeError("custom transport blew up"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "failed" in msg
|
||||
assert "RuntimeError" in msg
|
||||
assert _TEST_URL in msg
|
||||
|
||||
|
||||
# ---------- Response headers ------------------------------------------------
|
||||
|
||||
|
||||
class TestResponseHeaders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_headers_folded_with_commas(self) -> None:
|
||||
handler = StubHandler(
|
||||
_ok(
|
||||
"ok",
|
||||
headers={
|
||||
"Content-Type": ["application/json"],
|
||||
"Set-Cookie": ["a=1", "b=2"],
|
||||
},
|
||||
)
|
||||
)
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
h = decl["Local"]["H"]
|
||||
assert h["Content-Type"] == "application/json"
|
||||
assert h["Set-Cookie"] == "a=1,b=2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_headers_empty_assigned_none(self) -> None:
|
||||
handler = StubHandler(_ok("ok", headers={}))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["H"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_still_publishes_headers(self) -> None:
|
||||
handler = StubHandler(_err(status=500, body="boom", headers={"X-Trace": ["abc"]}))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
with pytest.raises(DeclarativeActionError):
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["H"] == {"X-Trace": "abc"}
|
||||
|
||||
|
||||
# ---------- ConversationId append -------------------------------------------
|
||||
|
||||
|
||||
class TestConversationAppend:
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_id_appends_message(self) -> None:
|
||||
handler = StubHandler(_ok('{"answer":"hello"}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
response="Local.Result",
|
||||
conversation_id="conv-test-1",
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
conv = decl["System"]["conversations"].get("conv-test-1")
|
||||
assert conv is not None
|
||||
assert len(conv["messages"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_conversation_id_does_not_append(self) -> None:
|
||||
handler = StubHandler(_ok('{"answer":"hello"}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result", conversation_id="")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# Auto-init creates an entry for the System.ConversationId conversation,
|
||||
# but it should NOT have HTTP-appended messages from us.
|
||||
for _cid, conv in decl["System"]["conversations"].items():
|
||||
assert conv["messages"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_body_skips_conversation_append(self) -> None:
|
||||
handler = StubHandler(_ok(""))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(conversation_id="conv-test-1")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# No conversation entry should have been created either.
|
||||
assert "conv-test-1" not in decl["System"]["conversations"]
|
||||
|
||||
|
||||
# ---------- Connection name -------------------------------------------------
|
||||
|
||||
|
||||
class TestConnection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_name_forwarded(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(connection={"name": "my-connection"})))
|
||||
await workflow.run({})
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.connection_name == "my-connection"
|
||||
|
||||
|
||||
# ---------- Build-time validation -------------------------------------------
|
||||
|
||||
|
||||
class TestBuildTimeValidation:
|
||||
def test_missing_url_fails_validation(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
bad = {
|
||||
"name": "no_url",
|
||||
"actions": [{"kind": "HttpRequestAction", "id": "x"}],
|
||||
}
|
||||
with pytest.raises(DeclarativeWorkflowError):
|
||||
factory.create_workflow_from_definition(bad)
|
||||
|
||||
def test_missing_handler_fails_at_build(self) -> None:
|
||||
factory = WorkflowFactory() # no handler
|
||||
with pytest.raises(DeclarativeWorkflowError) as excinfo:
|
||||
factory.create_workflow_from_definition(_yaml(_action()))
|
||||
assert "http_request_handler" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------- Timeout forwarding ----------------------------------------------
|
||||
|
||||
|
||||
class TestTimeout:
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_ms_forwarded(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=2500)))
|
||||
await workflow.run({})
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.timeout_ms == 2500
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_ms_zero_treated_as_unset(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=0)))
|
||||
await workflow.run({})
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.timeout_ms is None
|
||||
@@ -0,0 +1,111 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""End-to-end YAML integration test for ``HttpRequestAction``.
|
||||
|
||||
Loads the ``tests/workflows/http_request.yaml`` fixture (parity with the .NET
|
||||
integration fixture) through ``WorkflowFactory.create_workflow_from_yaml_path``
|
||||
with a stub :class:`HttpRequestHandler` and asserts state is populated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
not _powerfx_available,
|
||||
reason="powerfx not available — declarative workflows require it.",
|
||||
),
|
||||
pytest.mark.skipif(
|
||||
sys.version_info >= (3, 14),
|
||||
reason="Skipped on Python 3.14+ to keep parity with declarative suite.",
|
||||
),
|
||||
]
|
||||
|
||||
from agent_framework_declarative import WorkflowFactory # noqa: E402
|
||||
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY # noqa: E402
|
||||
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
)
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "workflows" / "http_request.yaml"
|
||||
|
||||
|
||||
class _StubHandler:
|
||||
"""Test double that records requests and returns a canned response."""
|
||||
|
||||
def __init__(self, result: HttpRequestResult) -> None:
|
||||
self._result = result
|
||||
self.received: list[HttpRequestInfo] = []
|
||||
|
||||
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
|
||||
self.received.append(info)
|
||||
return self._result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_request_yaml_roundtrip() -> None:
|
||||
handler = _StubHandler(
|
||||
HttpRequestResult(
|
||||
status_code=200,
|
||||
is_success_status_code=True,
|
||||
body='{"name": "runtime", "visibility": "public", "stars": 12345}',
|
||||
headers={
|
||||
"content-type": ["application/json"],
|
||||
"x-ratelimit-remaining": ["59"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_yaml_path(FIXTURE_PATH)
|
||||
await workflow.run({})
|
||||
|
||||
decl: dict[str, Any] = workflow._state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
local = decl.get("Local") or {}
|
||||
|
||||
assert local.get("RepoOwner") == "dotnet"
|
||||
repo_info = local.get("RepoInfo")
|
||||
assert isinstance(repo_info, dict), f"Expected dict body, got {type(repo_info)!r}"
|
||||
assert repo_info["name"] == "runtime"
|
||||
assert repo_info["visibility"] == "public"
|
||||
assert repo_info["stars"] == 12345
|
||||
|
||||
repo_headers = local.get("RepoHeaders")
|
||||
assert isinstance(repo_headers, dict)
|
||||
# Single-value header surfaces as plain string.
|
||||
assert repo_headers.get("content-type") == "application/json"
|
||||
assert repo_headers.get("x-ratelimit-remaining") == "59"
|
||||
|
||||
# Stub got the right call.
|
||||
assert len(handler.received) == 1
|
||||
sent = handler.received[0]
|
||||
assert sent.method == "GET"
|
||||
assert sent.url == "https://api.github.com/repos/dotnet/runtime"
|
||||
assert sent.headers["Accept"] == "application/vnd.github+json"
|
||||
assert sent.headers["User-Agent"] == "agent-framework-integration-test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_request_yaml_missing_handler_fails_at_build_time() -> None:
|
||||
"""Without an http_request_handler, building the workflow must raise."""
|
||||
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
|
||||
|
||||
factory = WorkflowFactory() # no handler configured
|
||||
with pytest.raises(DeclarativeWorkflowError) as excinfo:
|
||||
factory.create_workflow_from_yaml_path(FIXTURE_PATH)
|
||||
msg = str(excinfo.value)
|
||||
assert "HttpRequestAction" in msg
|
||||
assert "http_request_handler" in msg
|
||||
@@ -4,10 +4,8 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_declarative._workflows._factory import (
|
||||
DeclarativeWorkflowError,
|
||||
WorkflowFactory,
|
||||
)
|
||||
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
|
||||
from agent_framework_declarative._workflows._factory import WorkflowFactory
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#
|
||||
# Integration fixture: end-to-end HttpRequestAction round-trip using a
|
||||
# stub HttpRequestHandler. Mirrors the .NET integration fixture in
|
||||
# dotnet/tests/.../Workflows/HttpRequest.yaml.
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_http_request_test
|
||||
actions:
|
||||
|
||||
# Set the repo owner used to form the request URL.
|
||||
- kind: SetVariable
|
||||
id: set_repo_owner
|
||||
variable: Local.RepoOwner
|
||||
value: dotnet
|
||||
|
||||
# Invoke the (stubbed) GitHub repo API.
|
||||
- kind: HttpRequestAction
|
||||
id: fetch_repo_info
|
||||
conversationId: =System.ConversationId
|
||||
method: GET
|
||||
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
User-Agent: agent-framework-integration-test
|
||||
response: Local.RepoInfo
|
||||
responseHeaders: Local.RepoHeaders
|
||||
Reference in New Issue
Block a user