Files
copilotkit__copilotkit/sdk-python/tests/test_copilotkit_lg_middleware.py
Ben Taylor cd0d38a073 fix(sdk-python): restore frontend tool history faithfully in LangGraph middleware (#5308)
## Description

Fixes the LangGraph middleware "lying" to the agent about frontend tool
execution (#4759). When a model turn calls a `useFrontendTool` handler,
the middleware strips the FE tool calls so the backend `ToolNode` only
runs calls it actually made, then rehydrates them afterward. Previously
the rehydration could leave the restored AI message and its tool history
inconsistent, so providers that require every tool call to have a
matching result would break, and the agent saw an incorrect picture of
what executed.

## Changes

- `_restore_intercepted_tool_call_history` rebuilds the intercepted FE
tool calls with synthetic `ToolMessage` results only for the restored
model history (so providers that require every `tool_call` to have a
result stay valid), and preserves the original AI message's `name`,
`additional_kwargs`, and `response_metadata` on restore.
- Tracks `original_tool_calls` in the CopilotKit private state so the
original call set is restored exactly.

## Testing

`uv run pytest tests/test_copilotkit_lg_middleware.py` — 68 passed,
including new cases asserting the restored AI message keeps its
tool-call ids, name, `additional_kwargs`, and `response_metadata`, and
that tool results line up with their calls.

Closes #4759

AI was used for assistance.
2026-08-17 12:54:26 -05:00

2035 lines
73 KiB
Python

"""Behavior tests for ``CopilotKitMiddleware``.
The contract these tests pin down (independent of how the middleware is
implemented internally — we only assert on what the model/handler observes
and what state updates the middleware emits):
* Frontend tools listed in ``state["copilotkit"]["actions"]`` show up alongside
the agent's own tools when the model is called. When there are no frontend
tools the request reaches the model unchanged.
* App context from ``state["copilotkit"]["context"]`` (or ``runtime.context``)
is appended to ``ModelRequest.system_message`` as ``"App Context:\\n<json>"``.
Empty context is a no-op. ``before_agent`` does not mutate message history.
* ``after_model`` peels frontend tool calls off the last AIMessage so the
ToolNode does not try to execute them; later model calls re-attach them with
synthetic ToolMessages, while ``after_agent`` persists them as orphans for
the real frontend result.
* The ``expose_state`` opt-in surfaces user state into ``request.system_message``
as a ``"Current agent state:"`` note. Default is off; reserved internal
keys, underscore-prefixed keys, and empty values are filtered out; an
allowlist forces an explicit subset; any existing system message is kept
and the note appended to it.
* The Bedrock checkpoint normalizer drops orphan tool calls and dedupes
ToolMessages that share a ``tool_call_id``.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from ag_ui.core import RunAgentInput, UserMessage
from pydantic import Field
from typing_extensions import TypedDict
from langchain.agents import create_agent
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
from langchain_core.outputs import ChatGeneration, ChatResult
from langgraph.constants import END, START
from langgraph.checkpoint.memory import MemorySaver
from langchain.agents.middleware import ModelRequest
from langgraph.graph import StateGraph
from copilotkit.copilotkit_lg_middleware import (
CopilotKitMiddleware,
_extract_forwarded_headers_from_config,
)
from copilotkit.header_propagation import get_forwarded_headers, set_forwarded_headers
from copilotkit.langgraph_agui_agent import LangGraphAGUIAgent
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_request(
*,
state: dict[str, Any] | None = None,
tools: list[Any] | None = None,
system_message: SystemMessage | None = None,
messages: list[Any] | None = None,
) -> ModelRequest:
"""Build a ModelRequest with sensible defaults for testing."""
runtime = MagicMock(name="runtime")
runtime.context = None
return ModelRequest(
model=MagicMock(name="model"),
messages=messages if messages is not None else [],
system_message=system_message,
tools=tools if tools is not None else [],
state=state if state is not None else {"messages": []},
runtime=runtime,
)
class _CapturingHandler:
"""Records the request handed to the model wrapper."""
def __init__(self) -> None:
self.received: ModelRequest | None = None
def __call__(self, request: ModelRequest) -> str:
self.received = request
return "model-response"
def _run_wrap(middleware: CopilotKitMiddleware, request: ModelRequest):
"""Invoke the sync wrap_model_call with a capturing handler."""
handler = _CapturingHandler()
result = middleware.wrap_model_call(request, handler)
assert handler.received is not None, "handler must be called"
return handler.received, result
class _RecordingToolAwareChatModel(BaseChatModel):
"""Minimal model that records the messages/tools LangChain bound to it."""
bound_tools: list[Any] = Field(default_factory=list)
last_messages: list[BaseMessage] = Field(default_factory=list)
@property
def _llm_type(self) -> str:
return "recording-tool-aware-chat-model"
def bind_tools(self, tools, *, tool_choice=None, **kwargs):
self.bound_tools = list(tools)
return self
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager=None,
**kwargs: Any,
) -> ChatResult:
self.last_messages = list(messages)
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
class _ParentState(TypedDict):
messages: list[Any]
class _ParentContext(TypedDict, total=False):
copilotkit: dict[str, Any]
# ---------------------------------------------------------------------------
# Frontend-tool injection
# ---------------------------------------------------------------------------
def test_no_frontend_tools_passes_request_through_unchanged():
middleware = CopilotKitMiddleware()
backend_tool = {"name": "backend_tool"}
request = _make_request(state={"messages": []}, tools=[backend_tool])
seen, _ = _run_wrap(middleware, request)
assert seen.tools == [backend_tool]
def test_frontend_tools_appended_to_existing_tools():
middleware = CopilotKitMiddleware()
backend_tool = {"name": "backend_tool"}
fe_tools = [{"name": "fe_one"}, {"name": "fe_two"}]
request = _make_request(
state={"messages": [], "copilotkit": {"actions": fe_tools}},
tools=[backend_tool],
)
seen, _ = _run_wrap(middleware, request)
seen_names = [t["name"] for t in seen.tools]
assert "backend_tool" in seen_names
assert seen_names.count("fe_one") == 1
assert seen_names.count("fe_two") == 1
def test_frontend_tools_merge_does_not_mutate_input_request():
middleware = CopilotKitMiddleware()
request = _make_request(
state={"messages": [], "copilotkit": {"actions": [{"name": "fe"}]}},
tools=[{"name": "backend"}],
)
_run_wrap(middleware, request)
# The override(...) contract is to return a fresh request — the original
# tools list must not have grown.
assert [t["name"] for t in request.tools] == ["backend"]
# ---------------------------------------------------------------------------
# _get_copilotkit_context — fallback to LangGraph runtime carriers
# ---------------------------------------------------------------------------
def test_get_copilotkit_context_returns_state_level_when_present(monkeypatch):
"""When state["copilotkit"] has actions, return it unchanged."""
middleware = CopilotKitMiddleware()
state = {
"copilotkit": {
"actions": [{"name": "fe_one"}],
"context": "some context",
}
}
result = middleware._get_copilotkit_context(state)
assert result == state["copilotkit"]
def test_get_copilotkit_context_falls_back_to_langgraph_context(monkeypatch):
"""When state lacks copilotkit, prefer config.context.copilotkit."""
middleware = CopilotKitMiddleware()
config_copilotkit = {
"actions": [{"name": "fe_from_context"}],
"context": [{"description": "viewer role", "value": "admin"}],
}
def mock_get_config():
return {"context": {"copilotkit": config_copilotkit}}
monkeypatch.setattr(
"langgraph.config.get_config",
mock_get_config,
)
state = {}
result = middleware._get_copilotkit_context(state)
assert result == config_copilotkit
def test_get_copilotkit_context_falls_back_to_configurable_when_context_missing(
monkeypatch,
):
"""Older configurable-only carriers still work as a fallback."""
middleware = CopilotKitMiddleware()
config_copilotkit = {
"actions": [{"name": "fe_from_configurable"}],
"context": [{"description": "workspace", "value": "prod"}],
}
def mock_get_config():
return {"configurable": {"copilotkit": config_copilotkit}}
monkeypatch.setattr(
"langgraph.config.get_config",
mock_get_config,
)
result = middleware._get_copilotkit_context({})
assert result == config_copilotkit
def test_get_copilotkit_context_prefers_state_over_runtime_carriers(monkeypatch):
"""State-level copilotkit takes precedence over config/context fallbacks."""
middleware = CopilotKitMiddleware()
state = {
"copilotkit": {
"actions": [{"name": "fe_state"}],
}
}
def mock_get_config():
return {
"configurable": {
"copilotkit": {
"actions": [{"name": "fe_config"}],
}
}
}
monkeypatch.setattr(
"langgraph.config.get_config",
mock_get_config,
)
result = middleware._get_copilotkit_context(state)
assert result["actions"][0]["name"] == "fe_state"
def test_get_copilotkit_context_returns_empty_dict_when_not_found(monkeypatch):
"""When copilotkit is not in state or config, return empty dict."""
middleware = CopilotKitMiddleware()
def mock_get_config():
return {"configurable": {}}
monkeypatch.setattr(
"langgraph.config.get_config",
mock_get_config,
)
state = {}
result = middleware._get_copilotkit_context(state)
assert result == {}
def test_get_copilotkit_context_handles_missing_config(monkeypatch):
"""When get_config raises, return empty state copilotkit."""
middleware = CopilotKitMiddleware()
def mock_get_config():
raise RuntimeError("No active context")
monkeypatch.setattr(
"langgraph.config.get_config",
mock_get_config,
)
state = {}
result = middleware._get_copilotkit_context(state)
assert result == {}
def test_wrap_model_call_injects_frontend_tools_from_context_bridge(monkeypatch):
"""wrap_model_call uses the runtime context bridge when state lacks copilotkit."""
middleware = CopilotKitMiddleware()
backend_tool = {"name": "backend_tool"}
fe_tools = [{"name": "fe_from_context"}]
def mock_get_config():
return {"context": {"copilotkit": {"actions": fe_tools}}}
monkeypatch.setattr(
"langgraph.config.get_config",
mock_get_config,
)
# State has no copilotkit key, but config does.
request = _make_request(state={"messages": []}, tools=[backend_tool])
seen, _ = _run_wrap(middleware, request)
seen_names = [t["name"] for t in seen.tools]
assert "backend_tool" in seen_names
assert "fe_from_context" in seen_names
def test_real_subgraph_context_bridge_reaches_child_agent():
"""A real AG-UI run carries tools and app context into a child agent."""
model = _RecordingToolAwareChatModel()
middleware = CopilotKitMiddleware()
child_agent = create_agent(
model=model,
tools=[],
middleware=[middleware],
context_schema=_ParentContext,
)
parent = StateGraph(_ParentState, context_schema=_ParentContext)
parent.add_node("child", child_agent)
parent.add_edge(START, "child")
parent.add_edge("child", END)
agent = LangGraphAGUIAgent(
name="parent", graph=parent.compile(checkpointer=MemorySaver())
)
async def consume_run():
return [
event
async for event in agent.run(
RunAgentInput(
thread_id="t-1",
run_id="r-1",
state={},
messages=[UserMessage(id="m-1", content="hi")],
tools=[
{
"name": "frontend_lookup",
"description": "frontend tool",
}
],
context=[
{
"description": "viewer role",
"value": "admin",
}
],
forwarded_props={},
)
)
]
asyncio.run(consume_run())
assert model.last_messages, "child model should receive the parent run"
assert [tool.get("name") for tool in model.bound_tools] == ["frontend_lookup"]
system_messages = [
msg for msg in model.last_messages if isinstance(msg, SystemMessage)
]
assert system_messages, "middleware should inject an app-context system message"
assert any("App Context:" in msg.content for msg in system_messages)
assert any("admin" in msg.content for msg in system_messages)
# ---------------------------------------------------------------------------
# expose_state — opt-in state surfacing
# ---------------------------------------------------------------------------
def test_expose_state_default_is_off():
middleware = CopilotKitMiddleware()
request = _make_request(state={"messages": [], "liked": ["a", "b"]})
seen, _ = _run_wrap(middleware, request)
assert seen.system_message is None
def test_expose_state_true_surfaces_user_keys_into_system_message():
middleware = CopilotKitMiddleware(expose_state=True)
request = _make_request(state={"messages": [], "liked": ["a", "b"]})
seen, _ = _run_wrap(middleware, request)
assert seen.system_message is not None
body = seen.system_message.content
assert isinstance(body, str)
assert "Current agent state:" in body
assert '"liked"' in body
assert '"a"' in body
assert '"b"' in body
def test_expose_state_true_skips_reserved_internal_keys():
middleware = CopilotKitMiddleware(expose_state=True)
request = _make_request(
state={
"messages": [HumanMessage("hi")],
"tools": [{"name": "x"}],
"copilotkit": {"actions": []},
"structured_response": {"foo": "bar"},
"thread_id": "t-1",
"remaining_steps": 5,
"ag-ui": {"context": []},
"liked": ["a"],
}
)
seen, _ = _run_wrap(middleware, request)
body = seen.system_message.content if seen.system_message else ""
# Only the user key escapes the reserved filter.
assert '"liked"' in body
for reserved in (
"messages",
"tools",
"copilotkit",
"structured_response",
"thread_id",
"remaining_steps",
"ag-ui",
):
assert f'"{reserved}"' not in body, f"reserved key {reserved} leaked"
def test_expose_state_true_skips_underscore_prefixed_keys():
middleware = CopilotKitMiddleware(expose_state=True)
request = _make_request(
state={"messages": [], "_internal": {"secret": 1}, "visible": "ok"}
)
seen, _ = _run_wrap(middleware, request)
body = seen.system_message.content if seen.system_message else ""
assert '"_internal"' not in body
assert '"visible"' in body
@pytest.mark.parametrize("empty_value", [None, "", [], {}])
def test_expose_state_skips_empty_values(empty_value):
middleware = CopilotKitMiddleware(expose_state=True)
request = _make_request(
state={"messages": [], "filled": ["x"], "blank": empty_value}
)
seen, _ = _run_wrap(middleware, request)
if seen.system_message is None:
# Acceptable: nothing left to surface after dropping the empty key.
return
body = seen.system_message.content
assert '"filled"' in body
assert '"blank"' not in body
def test_expose_state_no_message_when_only_reserved_keys_present():
middleware = CopilotKitMiddleware(expose_state=True)
request = _make_request(state={"messages": [HumanMessage("hi")], "tools": []})
seen, _ = _run_wrap(middleware, request)
assert seen.system_message is None
def test_expose_state_allowlist_only_includes_named_keys():
middleware = CopilotKitMiddleware(expose_state=["liked"])
request = _make_request(
state={"messages": [], "liked": ["a"], "todos": [{"id": 1}], "other": "x"}
)
seen, _ = _run_wrap(middleware, request)
body = seen.system_message.content if seen.system_message else ""
assert '"liked"' in body
assert '"todos"' not in body
assert '"other"' not in body
def test_expose_state_allowlist_can_override_reserved_keys():
"""If the user explicitly lists a reserved key, honor their intent."""
middleware = CopilotKitMiddleware(expose_state=["thread_id"])
request = _make_request(state={"messages": [], "thread_id": "t-42"})
seen, _ = _run_wrap(middleware, request)
body = seen.system_message.content if seen.system_message else ""
assert "t-42" in body
def test_expose_state_appends_to_existing_system_message():
middleware = CopilotKitMiddleware(expose_state=True)
request = _make_request(
state={"messages": [], "liked": ["a"]},
system_message=SystemMessage(content="You are a helpful assistant."),
)
seen, _ = _run_wrap(middleware, request)
body = seen.system_message.content
assert isinstance(body, str)
assert "You are a helpful assistant." in body
assert "Current agent state:" in body
# Ordering: original prompt comes first, note follows.
assert body.index("You are a helpful assistant.") < body.index(
"Current agent state:"
)
def test_expose_state_false_explicitly_keeps_state_hidden():
middleware = CopilotKitMiddleware(expose_state=False)
request = _make_request(
state={"messages": [], "liked": ["a"]},
system_message=SystemMessage(content="base"),
)
seen, _ = _run_wrap(middleware, request)
assert seen.system_message is not None
assert seen.system_message.content == "base"
def test_expose_state_emits_valid_json_payload():
"""The 'Current agent state:' body parses cleanly as JSON."""
middleware = CopilotKitMiddleware(expose_state=True)
state = {"messages": [], "liked": ["a", "b"], "count": 3, "nested": {"k": "v"}}
request = _make_request(state=state)
seen, _ = _run_wrap(middleware, request)
body = seen.system_message.content
json_part = body.split("Current agent state:\n", 1)[1]
parsed = json.loads(json_part)
assert parsed == {"liked": ["a", "b"], "count": 3, "nested": {"k": "v"}}
def test_expose_state_true_never_surfaces_forwarded_headers():
"""``copilotkit_forwarded_headers`` is a transport-layer plumbing key — it
must NEVER reach the LLM prompt via the ``expose_state`` path either, even
when ``expose_state=True`` would otherwise serialize every non-reserved
top-level state key.
"""
middleware = CopilotKitMiddleware(expose_state=True)
request = _make_request(
state={
"messages": [],
"liked": ["a"],
"copilotkit_forwarded_headers": {
"x-aimock-context": "showcase/d6",
"x-aimock-strict": "true",
},
}
)
seen, _ = _run_wrap(middleware, request)
body = seen.system_message.content if seen.system_message else ""
# The genuine user key is still surfaced.
assert '"liked"' in body
# The transport-layer wrapper and its header values are not.
assert "copilotkit_forwarded_headers" not in body, (
"copilotkit_forwarded_headers must never appear in expose_state output"
)
assert "x-aimock-context" not in body, (
"forwarded header values must never appear in expose_state output"
)
assert "x-aimock-strict" not in body
assert "showcase/d6" not in body
def test_expose_state_allowlist_never_surfaces_forwarded_headers():
"""``copilotkit_forwarded_headers`` must NEVER reach the LLM prompt via the
``expose_state`` path — including the explicit allowlist form. The sibling
``test_expose_state_allowlist_can_override_reserved_keys`` pins that users
CAN allowlist other reserved keys (e.g. ``thread_id``) on purpose; this key
is the one exception, because it is a transport-layer wrapper for forwarded
request headers and rendering it would leak the raw headers into the prompt.
"""
middleware = CopilotKitMiddleware(
expose_state=frozenset({"copilotkit_forwarded_headers"})
)
request = _make_request(
state={
"messages": [],
"copilotkit_forwarded_headers": {
"x-aimock-context": "showcase/d6",
"x-aimock-strict": "true",
},
}
)
seen, _ = _run_wrap(middleware, request)
body = seen.system_message.content if seen.system_message else ""
assert "copilotkit_forwarded_headers" not in body, (
"copilotkit_forwarded_headers must never appear in allowlist output"
)
assert "x-aimock-context" not in body, (
"forwarded header values must never appear in allowlist output"
)
assert "x-aimock-strict" not in body
assert "showcase/d6" not in body
# ---------------------------------------------------------------------------
# App Context system prompt injection
# ---------------------------------------------------------------------------
def test_wrap_model_call_appends_app_context_to_existing_system_message():
middleware = CopilotKitMiddleware()
request = _make_request(
state={
"messages": [HumanMessage("previous turn")],
"copilotkit": {
"context": [{"description": "viewer role", "value": "admin"}]
},
},
messages=[HumanMessage("previous turn")],
system_message=SystemMessage(content="You are a helpful assistant."),
)
seen, _ = _run_wrap(middleware, request)
assert seen.system_message is not None
body = seen.system_message.content
assert isinstance(body, str)
assert "You are a helpful assistant." in body
assert "App Context:" in body
assert "admin" in body
assert body.index("You are a helpful assistant.") < body.index("App Context:")
assert all(
"App Context:" not in (m.content if isinstance(m.content, str) else "")
for m in seen.messages
)
def test_before_agent_does_not_inject_app_context_into_message_state():
middleware = CopilotKitMiddleware()
state = {
"messages": [HumanMessage("hi")],
"copilotkit": {"context": [{"description": "viewer role", "value": "admin"}]},
}
runtime = MagicMock(name="runtime", context=None)
result = middleware.before_agent(state, runtime)
assert result is None
# ---------------------------------------------------------------------------
# Async wrapper parity
# ---------------------------------------------------------------------------
def test_async_wrap_mirrors_sync_behavior_for_state_and_tools():
"""The async path applies the same state/tool augmentations as the sync one."""
middleware = CopilotKitMiddleware(expose_state=True)
request = _make_request(
state={
"messages": [],
"copilotkit": {"actions": [{"name": "fe"}]},
"liked": ["a"],
},
tools=[{"name": "backend"}],
)
received: dict[str, ModelRequest] = {}
async def handler(req: ModelRequest):
received["req"] = req
return "ok"
async def go():
return await middleware.awrap_model_call(request, handler)
result = asyncio.run(go())
seen = received["req"]
assert result == "ok"
assert {t["name"] for t in seen.tools} == {"backend", "fe"}
assert seen.system_message is not None
assert "Current agent state:" in seen.system_message.content
# ---------------------------------------------------------------------------
# App Context injection
# ---------------------------------------------------------------------------
def _system_message_text(request: ModelRequest) -> str:
assert request.system_message is not None
content = request.system_message.content
assert isinstance(content, str)
return content
def test_before_agent_no_context_returns_no_update():
middleware = CopilotKitMiddleware()
state = {"messages": [HumanMessage("hi")], "copilotkit": {}}
runtime = MagicMock(name="runtime", context=None)
result = middleware.before_agent(state, runtime)
assert result is None
def test_wrap_model_call_injects_app_context_system_message():
middleware = CopilotKitMiddleware()
request = _make_request(
state={
"messages": [HumanMessage("hi")],
"copilotkit": {
"context": [{"description": "viewer role", "value": "admin"}]
},
}
)
seen, _ = _run_wrap(middleware, request)
body = _system_message_text(seen)
assert "App Context:" in body
assert "admin" in body
def test_before_agent_repeated_calls_do_not_mutate_messages():
middleware = CopilotKitMiddleware()
state = {
"messages": [HumanMessage("hi")],
"copilotkit": {"context": [{"description": "k", "value": "v"}]},
}
runtime = MagicMock(name="runtime", context=None)
first = middleware.before_agent(state, runtime) or state
second = middleware.before_agent(first, runtime) or first
assert first is state
assert second is state
assert second["messages"] == [HumanMessage("hi")]
def test_wrap_model_call_uses_runtime_context_when_state_context_empty():
middleware = CopilotKitMiddleware()
request = _make_request(state={"messages": [HumanMessage("hi")], "copilotkit": {}})
request.runtime.context = "route=/dashboard"
seen, _ = _run_wrap(middleware, request)
body = _system_message_text(seen)
assert "/dashboard" in body
def test_before_agent_strips_copilotkit_forwarded_headers_from_runtime_context():
"""``copilotkit_forwarded_headers`` is a transport-layer plumbing key that
langgraph-api auto-copies from ``configurable`` into ``context``. It must
never be rendered into the LLM prompt as App Context — the forwarded-headers
httpx conveyance path reads it from a separate ContextVar.
When that key is the ONLY thing in ``runtime.context``, ``before_agent``
must treat it as empty App Context and not inject an "App Context:" system
message at all.
"""
middleware = CopilotKitMiddleware()
state = {"messages": [HumanMessage("hi")], "copilotkit": {}}
runtime = MagicMock(
name="runtime",
context={
"copilotkit_forwarded_headers": {
"x-aimock-context": "showcase/d6",
"x-aimock-strict": "true",
}
},
)
result = middleware.before_agent(state, runtime)
# Contract: when ``runtime.context`` contains ONLY
# ``copilotkit_forwarded_headers``, the strip leaves an empty App Context,
# so ``before_agent`` MUST short-circuit and return None (no App Context
# system message). A non-None result here means the strip regressed and
# the transport-layer wrapper is being injected into the prompt.
#
# NOTE: an earlier version of this test guarded the leak assertions with
# ``if result is not None``, which silently passed if the strip stopped
# short-circuiting — exactly the regression we need to catch. Assert
# explicitly instead.
assert result is None, (
"expected short-circuit (no App Context message) when only "
"copilotkit_forwarded_headers is present in runtime.context"
)
def test_wrap_model_call_strips_forwarded_headers_but_keeps_real_app_context():
"""When ``runtime.context`` contains both a genuine app key AND the
transport-only ``copilotkit_forwarded_headers`` wrapper, the App Context
system note must still be injected with the real key, but the forwarded
headers must be filtered out.
"""
middleware = CopilotKitMiddleware()
request = _make_request(state={"messages": [HumanMessage("hi")], "copilotkit": {}})
request.runtime.context = {
"user_tier": "pro",
"copilotkit_forwarded_headers": {
"x-aimock-context": "showcase/d6",
},
}
seen, _ = _run_wrap(middleware, request)
body = _system_message_text(seen)
# The genuine app context is still surfaced.
assert "App Context:" in body
assert "user_tier" in body
assert "pro" in body
# The transport-layer wrapper is stripped from the rendered prompt.
assert "copilotkit_forwarded_headers" not in body, (
"copilotkit_forwarded_headers must be filtered out of the App Context message"
)
assert "x-aimock-context" not in body, (
"forwarded header values must never appear in a system prompt"
)
# ---------------------------------------------------------------------------
# after_model — frontend tool-call interception
# ---------------------------------------------------------------------------
def test_after_model_no_frontend_tools_is_noop():
middleware = CopilotKitMiddleware()
state = {
"messages": [
HumanMessage("hi"),
AIMessage(
content="",
tool_calls=[{"id": "1", "name": "backend_only", "args": {}}],
),
],
"copilotkit": {"actions": []},
}
runtime = MagicMock(name="runtime")
assert middleware.after_model(state, runtime) is None
def test_after_model_intercepts_frontend_tool_calls_and_leaves_backend_alone():
middleware = CopilotKitMiddleware()
fe_tool = {"function": {"name": "navigate"}}
backend_call = {"id": "1", "name": "backend_search", "args": {"q": "hi"}}
frontend_call = {"id": "2", "name": "navigate", "args": {"path": "/x"}}
ai = AIMessage(
content="",
tool_calls=[backend_call, frontend_call],
id="ai-1",
name="assistant-name",
additional_kwargs={"provider": "test-provider"},
response_metadata={"finish_reason": "tool_calls"},
)
state = {
"messages": [HumanMessage("hi"), ai],
"copilotkit": {"actions": [fe_tool]},
}
runtime = MagicMock(name="runtime")
result = middleware.after_model(state, runtime)
assert result is not None
last = result["messages"][-1]
assert isinstance(last, AIMessage)
assert [tc["name"] for tc in last.tool_calls] == ["backend_search"]
assert last.name == "assistant-name"
assert last.additional_kwargs == {"provider": "test-provider"}
assert last.response_metadata == {"finish_reason": "tool_calls"}
intercepted = result["copilotkit"]["intercepted_tool_calls"]
assert len(intercepted) == 1
assert intercepted[0]["id"] == "2"
assert intercepted[0]["name"] == "navigate"
assert intercepted[0]["args"] == {"path": "/x"}
assert result["copilotkit"]["original_ai_message_id"] == "ai-1"
assert [tc["id"] for tc in result["copilotkit"]["original_tool_calls"]] == [
"1",
"2",
]
@pytest.mark.parametrize("use_async", [False, True])
def test_next_model_call_sees_request_scoped_frontend_tool_history(use_async):
middleware = CopilotKitMiddleware()
fe_tool = {"function": {"name": "navigate"}}
backend_call = {"id": "1", "name": "backend_search", "args": {"q": "hi"}}
frontend_call = {"id": "2", "name": "navigate", "args": {"path": "/x"}}
initial_state = {
"messages": [
HumanMessage("hi"),
AIMessage(
content="",
tool_calls=[backend_call, frontend_call],
id="ai-1",
name="assistant-name",
additional_kwargs={"provider": "test-provider"},
response_metadata={"finish_reason": "tool_calls"},
),
],
"copilotkit": {"actions": [fe_tool]},
}
after_model = middleware.after_model(initial_state, MagicMock(name="runtime"))
assert after_model is not None
messages = [
*after_model["messages"],
ToolMessage(content='{"hits": 1}', tool_call_id="1"),
]
state = {
"messages": messages,
"copilotkit": {
**initial_state["copilotkit"],
**after_model["copilotkit"],
},
}
request = _make_request(state=state, messages=messages)
if use_async:
received: dict[str, ModelRequest] = {}
async def handler(req: ModelRequest):
received["req"] = req
return "ok"
asyncio.run(middleware.awrap_model_call(request, handler))
seen = received["req"]
else:
seen, _ = _run_wrap(middleware, request)
ai = next(m for m in seen.messages if isinstance(m, AIMessage) and m.id == "ai-1")
assert [tc["id"] for tc in ai.tool_calls] == ["1", "2"]
assert ai.name == "assistant-name"
assert ai.additional_kwargs == {"provider": "test-provider"}
assert ai.response_metadata == {"finish_reason": "tool_calls"}
tool_messages = [m for m in seen.messages if isinstance(m, ToolMessage)]
assert [m.tool_call_id for m in tool_messages] == ["1", "2"]
assert tool_messages[0].content == '{"hits": 1}'
assert json.loads(tool_messages[1].content) == {"status": "forwarded_to_frontend"}
persisted_ai = next(
m for m in messages if isinstance(m, AIMessage) and m.id == "ai-1"
)
assert [tc["id"] for tc in persisted_ai.tool_calls] == ["1"]
persisted_tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert [m.tool_call_id for m in persisted_tool_messages] == ["1"]
def test_next_model_call_preserves_multiple_frontend_tool_calls_in_order():
middleware = CopilotKitMiddleware()
fe_tools = [{"name": "select_tab"}, {"name": "navigate"}]
tool_calls = [
{"id": "fe-1", "name": "select_tab", "args": {"tab": "overview"}},
{"id": "be-1", "name": "backend_search", "args": {"q": "hi"}},
{"id": "be-2", "name": "backend_fetch", "args": {"id": "42"}},
{"id": "fe-2", "name": "navigate", "args": {"path": "/x"}},
]
initial_state = {
"messages": [
HumanMessage("hi"),
AIMessage(content="", tool_calls=tool_calls, id="ai"),
],
"copilotkit": {"actions": fe_tools},
}
after_model = middleware.after_model(initial_state, MagicMock(name="runtime"))
assert after_model is not None
assert [tc["id"] for tc in after_model["messages"][-1].tool_calls] == [
"be-1",
"be-2",
]
messages = [
*after_model["messages"],
ToolMessage(content="ok", tool_call_id="be-1"),
ToolMessage(content="ok", tool_call_id="be-2"),
]
state = {
"messages": messages,
"copilotkit": {
**initial_state["copilotkit"],
**after_model["copilotkit"],
},
}
request = _make_request(state=state, messages=messages)
seen, _ = _run_wrap(middleware, request)
ai = next(m for m in seen.messages if isinstance(m, AIMessage) and m.id == "ai")
assert [tc["id"] for tc in ai.tool_calls] == ["fe-1", "be-1", "be-2", "fe-2"]
tool_messages = [m for m in seen.messages if isinstance(m, ToolMessage)]
assert [m.tool_call_id for m in tool_messages] == [
"fe-1",
"be-1",
"be-2",
"fe-2",
]
assert json.loads(tool_messages[0].content) == {"status": "forwarded_to_frontend"}
assert json.loads(tool_messages[-1].content) == {"status": "forwarded_to_frontend"}
def test_next_model_call_preserves_frontend_history_when_backend_tool_errors():
middleware = CopilotKitMiddleware()
fe_tool = {"name": "navigate"}
backend_call = {"id": "be-1", "name": "backend_search", "args": {"q": "hi"}}
frontend_call = {"id": "fe-1", "name": "navigate", "args": {"path": "/x"}}
initial_state = {
"messages": [
HumanMessage("hi"),
AIMessage(
content="",
tool_calls=[backend_call, frontend_call],
id="ai",
),
],
"copilotkit": {"actions": [fe_tool]},
}
after_model = middleware.after_model(initial_state, MagicMock(name="runtime"))
assert after_model is not None
messages = [
*after_model["messages"],
ToolMessage(content="backend failed", tool_call_id="be-1", status="error"),
]
state = {
"messages": messages,
"copilotkit": {
**initial_state["copilotkit"],
**after_model["copilotkit"],
},
}
request = _make_request(state=state, messages=messages)
seen, _ = _run_wrap(middleware, request)
ai = next(m for m in seen.messages if isinstance(m, AIMessage) and m.id == "ai")
assert [tc["id"] for tc in ai.tool_calls] == ["be-1", "fe-1"]
tool_messages = [m for m in seen.messages if isinstance(m, ToolMessage)]
assert [m.tool_call_id for m in tool_messages] == ["be-1", "fe-1"]
assert tool_messages[0].content == "backend failed"
assert getattr(tool_messages[0], "status", None) == "error"
assert json.loads(tool_messages[1].content) == {"status": "forwarded_to_frontend"}
# ---------------------------------------------------------------------------
# after_agent — frontend tool-call restoration
# ---------------------------------------------------------------------------
def test_after_agent_no_intercepted_returns_no_update():
middleware = CopilotKitMiddleware()
state = {
"messages": [HumanMessage("hi"), AIMessage(content="ok", id="ai-1")],
"copilotkit": {},
}
runtime = MagicMock(name="runtime")
assert middleware.after_agent(state, runtime) is None
def test_after_agent_restores_intercepted_tool_calls_on_original_message():
middleware = CopilotKitMiddleware()
backend_call = {"id": "1", "name": "backend_search", "args": {"q": "hi"}}
frontend_call = {"id": "2", "name": "navigate", "args": {"path": "/x"}}
state = {
"messages": [
HumanMessage("hi"),
AIMessage(content="", tool_calls=[backend_call], id="ai-1"),
ToolMessage(content="ok", tool_call_id="1"),
],
"copilotkit": {
"intercepted_tool_calls": [frontend_call],
"original_ai_message_id": "ai-1",
"original_tool_calls": [backend_call, frontend_call],
},
}
runtime = MagicMock(name="runtime")
result = middleware.after_agent(state, runtime)
assert result is not None
restored_ai = next(
m for m in result["messages"] if isinstance(m, AIMessage) and m.id == "ai-1"
)
assert [tc["id"] for tc in restored_ai.tool_calls] == ["1", "2"]
tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
assert [m.tool_call_id for m in tool_messages] == ["1"]
assert result["copilotkit"]["intercepted_tool_calls"] is None
assert result["copilotkit"]["original_ai_message_id"] is None
assert result["copilotkit"]["original_tool_calls"] is None
# ---------------------------------------------------------------------------
# Bedrock checkpoint normalizer — message-list contract
# ---------------------------------------------------------------------------
def test_bedrock_fix_strips_unanswered_tool_calls_from_ai_message():
ai = AIMessage(
content="",
tool_calls=[
{"id": "answered", "name": "search", "args": {}},
{"id": "orphan", "name": "search", "args": {}},
],
id="ai-1",
)
answered = ToolMessage(content="result", tool_call_id="answered")
messages: list[Any] = [HumanMessage("hi"), ai, answered]
CopilotKitMiddleware._fix_messages_for_bedrock(messages)
repaired_ai = next(m for m in messages if isinstance(m, AIMessage))
assert [tc["id"] for tc in repaired_ai.tool_calls] == ["answered"]
def test_sync_wrap_dedupes_tool_messages_with_shared_id():
"""Real result wins over an interrupted placeholder for the same id."""
ai = AIMessage(
content="",
tool_calls=[{"id": "tc-1", "name": "search", "args": {}}],
id="ai-1",
)
placeholder = ToolMessage(
content="Tool call 'search' with id 'tc-1' was interrupted before completion.",
tool_call_id="tc-1",
)
real = ToolMessage(content='{"hits": 3}', tool_call_id="tc-1")
messages: list[Any] = [HumanMessage("hi"), ai, placeholder, real]
request = _make_request(state={"messages": messages}, messages=messages)
seen, _ = _run_wrap(CopilotKitMiddleware(), request)
tool_messages = [m for m in seen.messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert tool_messages[0].content == '{"hits": 3}'
def test_bedrock_fix_repairs_string_args_to_dicts():
# Construct cleanly, then corrupt the args to simulate what
# checkpoints sometimes produce (str instead of dict).
ai = AIMessage(
content="",
tool_calls=[{"id": "tc-1", "name": "search", "args": {}}],
id="ai-1",
)
ai.tool_calls[0]["args"] = '{"q": "hello"}'
answered = ToolMessage(content="ok", tool_call_id="tc-1")
messages: list[Any] = [HumanMessage("hi"), ai, answered]
CopilotKitMiddleware._fix_messages_for_bedrock(messages)
repaired = next(m for m in messages if isinstance(m, AIMessage))
assert repaired.tool_calls[0]["args"] == {"q": "hello"}
# ---------------------------------------------------------------------------
# Checkpoint roundtrip — orphan-handoff contract
# ---------------------------------------------------------------------------
# These tests exercise the full checkpoint roundtrip path:
# 1. after_agent leaves FE tool call as orphan (no ToolMessage)
# 2. Checkpoint saved with orphan state
# 3. Checkpoint loaded; patch_orphan_tool_calls adds _INTERRUPTED_PAT placeholder
# 4. Real frontend result arrives (appended to messages)
# 5. _fix_messages_for_bedrock dedupes: real result replaces placeholder
#
# The key invariant is that after_agent MUST NOT persist synthetic
# ToolMessages — only the tool_calls are restored to the AIMessage.
def test_checkpoint_roundtrip_placeholder_replaced_by_real_result():
"""Simulates checkpoint restore with patch_orphan_tool_calls placeholder,
then real FE result arriving — the Bedrock fix should replace the
placeholder with the real result, keeping the result adjacent to the
AIMessage.
"""
# State after checkpoint restore: patch_orphan_tool_calls added a
# placeholder for the orphan FE tool call. The real result is appended
# at the end (e.g. by the AG-UI adapter or add_messages).
ai = AIMessage(
content="",
tool_calls=[
{"id": "be-1", "name": "backend_search", "args": {}},
{"id": "fe-1", "name": "navigate", "args": {"path": "/x"}},
],
id="ai-1",
)
backend_result = ToolMessage(content='{"hits": 3}', tool_call_id="be-1")
# patch_orphan_tool_calls injects this pattern for orphan tool calls
placeholder = ToolMessage(
content="Tool call 'navigate' with id 'fe-1' was interrupted before completion.",
tool_call_id="fe-1",
)
# Real FE result arrives later, appended at end
real_fe_result = ToolMessage(
content='{"navigated_to": "/x"}',
tool_call_id="fe-1",
)
messages: list[Any] = [
HumanMessage("hi"),
ai,
backend_result,
placeholder,
real_fe_result,
]
CopilotKitMiddleware._fix_messages_for_bedrock(messages)
# The real result should replace the placeholder at the adjacent position
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 2, "Should have backend + FE results only"
assert tool_messages[0].tool_call_id == "be-1"
assert tool_messages[0].content == '{"hits": 3}'
assert tool_messages[1].tool_call_id == "fe-1"
assert tool_messages[1].content == '{"navigated_to": "/x"}'
def test_checkpoint_roundtrip_multiple_fe_calls_with_placeholders():
"""Multiple FE tool calls, all orphaned after turn 1, each gets a
placeholder from patch_orphan_tool_calls. Real results arrive for all.
"""
ai = AIMessage(
content="",
tool_calls=[
{"id": "fe-1", "name": "select_tab", "args": {"tab": "overview"}},
{"id": "be-1", "name": "search", "args": {"q": "hi"}},
{"id": "fe-2", "name": "navigate", "args": {"path": "/x"}},
],
id="ai-1",
)
placeholder_fe1 = ToolMessage(
content="Tool call 'select_tab' with id 'fe-1' was interrupted before completion.",
tool_call_id="fe-1",
)
backend_result = ToolMessage(content='{"hits": 5}', tool_call_id="be-1")
placeholder_fe2 = ToolMessage(
content="Tool call 'navigate' with id 'fe-2' was interrupted before completion.",
tool_call_id="fe-2",
)
real_fe1 = ToolMessage(content='{"tab": "overview"}', tool_call_id="fe-1")
real_fe2 = ToolMessage(content='{"path": "/x"}', tool_call_id="fe-2")
messages: list[Any] = [
HumanMessage("hi"),
ai,
placeholder_fe1,
backend_result,
placeholder_fe2,
real_fe1, # appended later
real_fe2, # appended later
]
CopilotKitMiddleware._fix_messages_for_bedrock(messages)
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 3, "Should have 3 results: fe-1, be-1, fe-2"
# The dedup replaces placeholders with real results
assert all(
"was interrupted before completion" not in m.content for m in tool_messages
), "All placeholders should be replaced"
ids = [m.tool_call_id for m in tool_messages]
assert set(ids) == {"fe-1", "be-1", "fe-2"}
def test_after_agent_leaves_fe_call_orphaned_for_checkpoint():
"""Verify that after_agent does not add any ToolMessage for FE calls —
the FE tool call is left orphaned so the checkpoint can receive the real
frontend result later via add_messages merge.
"""
middleware = CopilotKitMiddleware()
backend_call = {"id": "be-1", "name": "search", "args": {"q": "hi"}}
frontend_call = {"id": "fe-1", "name": "navigate", "args": {"path": "/x"}}
# State after backend execution + mid-loop model calls complete
state = {
"messages": [
HumanMessage("hi"),
AIMessage(content="", tool_calls=[backend_call], id="ai-1"),
ToolMessage(content="ok", tool_call_id="be-1"),
],
"copilotkit": {
"intercepted_tool_calls": [frontend_call],
"original_ai_message_id": "ai-1",
"original_tool_calls": [backend_call, frontend_call],
},
}
runtime = MagicMock(name="runtime")
result = middleware.after_agent(state, runtime)
assert result is not None
# AIMessage should have both tool_calls restored
restored_ai = next(
m for m in result["messages"] if isinstance(m, AIMessage) and m.id == "ai-1"
)
assert [tc["id"] for tc in restored_ai.tool_calls] == ["be-1", "fe-1"]
# Critical: only the backend ToolMessage should exist — the FE call is
# left as an orphan for the real frontend result to fill.
tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1, "Only backend ToolMessage should exist"
assert tool_messages[0].tool_call_id == "be-1"
# No synthetic FE ToolMessage should be present
assert not any(
"forwarded_to_frontend" in str(m.content)
for m in result["messages"]
if isinstance(m, ToolMessage)
), "No synthetic FE ToolMessage should be in after_agent output"
# ---------------------------------------------------------------------------
# _extract_forwarded_headers_from_config — raw x-* header extraction
# ---------------------------------------------------------------------------
class TestExtractForwardedHeadersFromConfig:
"""Verify that raw x-* keys on config["configurable"] and config["context"]
are extracted and pushed into the header-propagation ContextVar."""
def _patch_get_config(self, monkeypatch, config: dict):
"""Patch langgraph.config.get_config to return *config*."""
monkeypatch.setattr(
"copilotkit.copilotkit_lg_middleware.get_config",
lambda: config,
raising=False,
)
# Also patch at the import site inside the function's local scope:
# _extract_forwarded_headers_from_config does a local import, so we
# need to patch the module it imports from.
import langgraph.config as _lg_config
monkeypatch.setattr(_lg_config, "get_config", lambda: config)
def setup_method(self):
"""Reset forwarded headers before each test."""
set_forwarded_headers({})
def test_raw_x_header_on_configurable_is_extracted(self, monkeypatch):
self._patch_get_config(
monkeypatch,
{
"configurable": {
"thread_id": "t-1",
"x-aimock-context": "showcase/d5",
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers["x-aimock-context"] == "showcase/d5"
def test_raw_x_header_on_context_is_extracted(self, monkeypatch):
self._patch_get_config(
monkeypatch,
{
"context": {
"x-aimock-strict": "true",
},
"configurable": {},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers["x-aimock-strict"] == "true"
def test_non_x_keys_on_configurable_are_not_extracted(self, monkeypatch):
self._patch_get_config(
monkeypatch,
{
"configurable": {
"thread_id": "t-1",
"user_id": "u-42",
"checkpoint_ns": "",
"x-aimock-context": "test",
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert "thread_id" not in headers
assert "user_id" not in headers
assert "checkpoint_ns" not in headers
assert headers == {"x-aimock-context": "test"}
def test_wrapper_dict_still_works(self, monkeypatch):
"""Backward compat: the copilotkit_forwarded_headers wrapper dict
is still the preferred source."""
self._patch_get_config(
monkeypatch,
{
"configurable": {
"copilotkit_forwarded_headers": {
"x-aimock-strict": "true",
"x-custom-trace": "abc",
},
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers["x-aimock-strict"] == "true"
assert headers["x-custom-trace"] == "abc"
def test_wrapper_dict_takes_precedence_over_raw_key(self, monkeypatch):
"""When both the wrapper dict and a raw key provide the same header,
the wrapper-dict value wins."""
self._patch_get_config(
monkeypatch,
{
"configurable": {
"copilotkit_forwarded_headers": {
"x-aimock-context": "from-wrapper",
},
"x-aimock-context": "from-raw",
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers["x-aimock-context"] == "from-wrapper"
def test_wrapper_dict_keys_lowercased_at_insertion(self, monkeypatch):
"""Wrapper-dict keys must be lowercased at insertion so that
documented context > configurable precedence holds regardless of
the casing the agent author used."""
self._patch_get_config(
monkeypatch,
{
"context": {
"copilotkit_forwarded_headers": {
"X-Trace": "from-context",
},
},
"configurable": {
"copilotkit_forwarded_headers": {
"x-trace": "from-configurable",
},
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
# Context wins via first-write-wins (both lowercase to "x-trace").
assert headers["x-trace"] == "from-context"
# Only the lowercased key exists — no mixed-case duplicate.
assert "X-Trace" not in headers
def test_multiple_raw_x_headers_extracted(self, monkeypatch):
self._patch_get_config(
monkeypatch,
{
"configurable": {
"x-aimock-context": "showcase/d5",
"x-aimock-strict": "true",
"x-request-id": "req-123",
"thread_id": "t-1",
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers == {
"x-aimock-context": "showcase/d5",
"x-aimock-strict": "true",
"x-request-id": "req-123",
}
def test_no_headers_when_config_has_no_x_keys(self, monkeypatch):
self._patch_get_config(
monkeypatch,
{
"configurable": {
"thread_id": "t-1",
"user_id": "u-42",
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers == {}
def test_runtime_error_clears_contextvar(self):
"""When get_config() raises RuntimeError (not inside a runnable),
the function clears the ContextVar so stale headers from a prior
request do not leak through."""
set_forwarded_headers({"x-stale": "leftover"})
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers == {}
def test_non_string_values_are_skipped(self, monkeypatch):
"""Only string values are extracted; lists/dicts/ints are ignored."""
self._patch_get_config(
monkeypatch,
{
"configurable": {
"x-valid": "yes",
"x-list-value": ["a", "b"],
"x-int-value": 42,
"x-dict-value": {"nested": True},
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers == {"x-valid": "yes"}
def test_contextvar_cleared_when_no_headers(self, monkeypatch):
"""When the current call has no x-* headers, the ContextVar must be
reset to an empty dict so stale headers from a previous call in the
same async context do not leak through."""
# Pre-populate the ContextVar with stale headers.
set_forwarded_headers({"x-stale": "leftover"})
assert get_forwarded_headers() == {"x-stale": "leftover"}
# Config has no x-* keys at all.
self._patch_get_config(
monkeypatch,
{
"configurable": {
"thread_id": "t-1",
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers == {}
def test_exception_safety_unexpected_config_shape(self, monkeypatch):
"""If the config has an unexpected shape that raises during
extraction, the function must not propagate the exception — header
forwarding is best-effort and must never block the LLM call.
Additionally, stale headers from a prior request must be cleared."""
class _ExplodingDict:
"""A dict-like that raises on .get() to simulate unexpected shapes."""
def get(self, key, default=None):
raise TypeError(f"boom on {key}")
import langgraph.config as _lg_config
monkeypatch.setattr(_lg_config, "get_config", lambda: _ExplodingDict())
# Pre-populate stale headers.
set_forwarded_headers({"x-stale": "leftover"})
# Must not raise.
_extract_forwarded_headers_from_config()
# The ContextVar must be cleared so stale headers don't leak.
headers = get_forwarded_headers()
assert headers == {}
def test_context_wins_over_configurable_in_wrapper_dict(self, monkeypatch):
"""When both config["context"] and config["configurable"] have
copilotkit_forwarded_headers with the same key, the context value
wins (LangGraph >=0.6.0 introduced context as the newer preferred
mechanism)."""
self._patch_get_config(
monkeypatch,
{
"context": {
"copilotkit_forwarded_headers": {
"x-aimock-context": "from-context",
},
},
"configurable": {
"copilotkit_forwarded_headers": {
"x-aimock-context": "from-configurable",
},
},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers["x-aimock-context"] == "from-context"
# -- F1: Integration test — wrap_model_call invokes header extraction ------
def test_wrap_model_call_invokes_header_extraction(self, monkeypatch):
"""Removing the _extract_forwarded_headers_from_config() call from
wrap_model_call would cause this test to fail, proving the call site
is exercised end-to-end."""
self._patch_get_config(
monkeypatch,
{
"configurable": {"x-aimock-context": "via-wrap-model-call"},
},
)
captured_headers: dict[str, str] = {}
def handler(request):
captured_headers.update(get_forwarded_headers())
return "model-response"
middleware = CopilotKitMiddleware()
request = _make_request(state={"messages": []})
middleware.wrap_model_call(request, handler)
assert captured_headers.get("x-aimock-context") == "via-wrap-model-call"
# -- F2: Integration test — awrap_model_call (async) invokes extraction ----
def test_awrap_model_call_invokes_header_extraction(self, monkeypatch):
"""Same as the sync test above but exercising the async code path."""
self._patch_get_config(
monkeypatch,
{
"configurable": {"x-aimock-context": "via-awrap-model-call"},
},
)
captured_headers: dict[str, str] = {}
async def handler(request):
captured_headers.update(get_forwarded_headers())
return "model-response"
middleware = CopilotKitMiddleware()
request = _make_request(state={"messages": []})
asyncio.run(middleware.awrap_model_call(request, handler))
assert captured_headers.get("x-aimock-context") == "via-awrap-model-call"
# -- F3: Wrapper dict on config["context"] only (LangGraph >=0.6.0) --------
def test_wrapper_dict_on_context_only(self, monkeypatch):
"""The copilotkit_forwarded_headers wrapper dict on config['context']
(not configurable) must also be extracted — this is the LangGraph
>=0.6.0 path."""
self._patch_get_config(
monkeypatch,
{
"context": {
"copilotkit_forwarded_headers": {"x-aimock-strict": "true"},
},
"configurable": {},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers.get("x-aimock-strict") == "true"
# -- F6: None values for context / configurable (the `or {}` fallback) -----
def test_none_context_falls_back_to_configurable(self, monkeypatch):
"""config['context'] = None must not crash; headers from configurable
should still be extracted via the `or {}` fallback."""
self._patch_get_config(
monkeypatch,
{
"context": None,
"configurable": {"x-aimock-context": "via-raw"},
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers.get("x-aimock-context") == "via-raw"
def test_none_configurable_falls_back_to_context(self, monkeypatch):
"""config['configurable'] = None must not crash; headers from context
should still be extracted via the `or {}` fallback."""
self._patch_get_config(
monkeypatch,
{
"context": {"x-aimock-context": "via-context"},
"configurable": None,
},
)
_extract_forwarded_headers_from_config()
headers = get_forwarded_headers()
assert headers.get("x-aimock-context") == "via-context"
# ---------------------------------------------------------------------------
# Auto-A2UI — middleware injects + executes generate_a2ui when the frontend
# registered a catalog (surfaced into state["ag-ui"]["a2ui_schema"])
# ---------------------------------------------------------------------------
#
# Contract: the developer passes nothing — using the middleware is enough.
# generate_a2ui is advertised to the model only when an A2UI catalog is
# present, is built from the agent's own (inferred) model, and is executed by
# the middleware itself (it is never in the agent's static tool registry).
from langgraph.prebuilt.tool_node import ToolCallRequest # noqa: E402
from copilotkit.copilotkit_lg_middleware import ( # noqa: E402
get_a2ui_tools,
_a2ui_tools_by_thread,
)
_a2ui_unavailable = pytest.mark.skipif(
get_a2ui_tools is None,
reason=(
"ag-ui-langgraph without get_a2ui_tools; the single-arg "
"A2UIToolParams API needs the OSS-248 release (>=0.0.41)"
),
)
def _make_tool_request(name: str, *, tool: Any = None, state: dict | None = None):
return ToolCallRequest(
tool_call={"name": name, "id": "tc-1", "args": {}},
tool=tool,
state=state if state is not None else {"messages": []},
runtime=MagicMock(name="runtime"),
)
def _run_wrap_tool(middleware: CopilotKitMiddleware, request: ToolCallRequest):
handler = _CapturingHandler()
middleware.wrap_tool_call(request, handler)
return handler.received
# A2UI is opt-in: injection requires the inject_a2ui_tool flag (forwarded by the
# A2UI middleware and surfaced into ag-ui state). The catalog/schema only binds
# generated surfaces; it is not the gate.
_A2UI_STATE = {
"messages": [],
"ag-ui": {"a2ui_schema": "<components/>", "inject_a2ui_tool": True},
}
@_a2ui_unavailable
class TestAutoA2UI:
def setup_method(self) -> None:
# Isolate the module-level bridge between tests (thread id is None in
# unit tests, so all calls share _DEFAULT_THREAD_KEY).
_a2ui_tools_by_thread.clear()
def test_not_injected_without_flag(self):
middleware = CopilotKitMiddleware()
request = _make_request(state={"messages": []}, tools=[{"name": "backend"}])
seen, _ = _run_wrap(middleware, request)
assert [t["name"] for t in seen.tools] == ["backend"]
def test_not_injected_when_flag_absent_even_with_catalog(self):
"""Opt-in: a catalog alone never triggers injection without the flag."""
middleware = CopilotKitMiddleware()
state = {"messages": [], "ag-ui": {"a2ui_schema": "<components/>"}}
request = _make_request(state=state, tools=[{"name": "backend"}])
seen, _ = _run_wrap(middleware, request)
names = [getattr(t, "name", None) or t.get("name") for t in seen.tools]
assert "generate_a2ui" not in names
assert "backend" in names
def test_injected_when_flag_present(self):
middleware = CopilotKitMiddleware()
request = _make_request(state=dict(_A2UI_STATE), tools=[{"name": "backend"}])
seen, _ = _run_wrap(middleware, request)
names = [getattr(t, "name", None) or t.get("name") for t in seen.tools]
assert "backend" in names
assert "generate_a2ui" in names
# --- host a2ui_params override (the design-guidelines parity gap) ---------
#
# Before this, the auto-inject path hardwired get_a2ui_tools to the toolkit
# defaults: a host serving via CopilotKitMiddleware() could NOT override the
# design/generation guidelines (e.g. to favor a repeating-card layout). The
# a2ui_params kwarg threads a host override through; the middleware still
# injects the bound model and folds the registered catalog in (host wins).
@staticmethod
def _spy_get_a2ui_tools(monkeypatch) -> "list[dict]":
"""Patch get_a2ui_tools to capture the params dict it is called with and
return a stub tool named generate_a2ui. Returns the capture list."""
captured: "list[dict]" = []
def _spy(params):
captured.append(params)
stub = MagicMock(name="generate_a2ui_tool")
stub.name = "generate_a2ui"
return stub
monkeypatch.setattr("copilotkit.copilotkit_lg_middleware.get_a2ui_tools", _spy)
return captured
# Runtime-proxy path: a registered catalog arrives as a context entry, so
# the middleware derives a component_schema + catalog_id to fold in.
_A2UI_CONTEXT_STATE = {
"messages": [],
"ag-ui": {"inject_a2ui_tool": True},
"copilotkit": {
"context": [
{
"description": "A2UI catalog capabilities",
"value": "Available A2UI catalog:\n- my-custom-catalog\n"
" - Card: {...}",
}
]
},
}
def test_host_a2ui_params_guidelines_reach_subagent(self, monkeypatch):
captured = self._spy_get_a2ui_tools(monkeypatch)
middleware = CopilotKitMiddleware(
a2ui_params={"guidelines": {"design_guidelines": "REPEAT_CARDS_MARK"}}
)
request = _make_request(state=dict(_A2UI_STATE), tools=[])
_run_wrap(middleware, request)
assert len(captured) == 1
params = captured[0]
# Host override survives...
assert params["guidelines"]["design_guidelines"] == "REPEAT_CARDS_MARK"
# ...the middleware still injects the bound model...
assert params["model"] is request.model
# ...and the native path contributes no composition_guide.
assert "composition_guide" not in params["guidelines"]
def test_host_override_merges_with_registered_catalog(self, monkeypatch):
captured = self._spy_get_a2ui_tools(monkeypatch)
middleware = CopilotKitMiddleware(
a2ui_params={"guidelines": {"design_guidelines": "REPEAT_CARDS_MARK"}}
)
_run_wrap(
middleware, _make_request(state=dict(self._A2UI_CONTEXT_STATE), tools=[])
)
params = captured[0]
# Host guidance preserved alongside the catalog the middleware folded in.
assert params["guidelines"]["design_guidelines"] == "REPEAT_CARDS_MARK"
assert "my-custom-catalog" in params["guidelines"]["composition_guide"]
assert params["default_catalog_id"] == "my-custom-catalog"
def test_host_composition_guide_and_catalog_id_win(self, monkeypatch):
captured = self._spy_get_a2ui_tools(monkeypatch)
middleware = CopilotKitMiddleware(
a2ui_params={
"default_catalog_id": "host-catalog",
"guidelines": {"composition_guide": "HOST_COMP"},
}
)
_run_wrap(
middleware, _make_request(state=dict(self._A2UI_CONTEXT_STATE), tools=[])
)
params = captured[0]
# Host-set values are never clobbered by the registered catalog.
assert params["guidelines"]["composition_guide"] == "HOST_COMP"
assert params["default_catalog_id"] == "host-catalog"
def test_default_no_params_carries_only_model(self, monkeypatch):
captured = self._spy_get_a2ui_tools(monkeypatch)
middleware = CopilotKitMiddleware()
request = _make_request(state=dict(_A2UI_STATE), tools=[])
_run_wrap(middleware, request)
params = captured[0]
# Prior behavior intact: just the inferred model, no host guidelines, no
# catalog (native path with a non-JSON schema yields neither).
assert params["model"] is request.model
assert "guidelines" not in params
assert "default_catalog_id" not in params
def test_executed_via_wrap_tool_call_with_inferred_model(self):
middleware = CopilotKitMiddleware()
# Model call infers the model + stashes the built tool.
_run_wrap(middleware, _make_request(state=dict(_A2UI_STATE), tools=[]))
received = _run_wrap_tool(
middleware, _make_tool_request("generate_a2ui", tool=None)
)
assert received.tool is not None
assert received.tool.name == "generate_a2ui"
def test_other_tool_call_passes_through_untouched(self):
middleware = CopilotKitMiddleware()
_run_wrap(middleware, _make_request(state=dict(_A2UI_STATE), tools=[]))
backend_tool = MagicMock(name="backend")
received = _run_wrap_tool(
middleware, _make_tool_request("backend", tool=backend_tool)
)
assert received.tool is backend_tool
def test_bridge_cleared_after_agent_stops_execution(self):
middleware = CopilotKitMiddleware()
_run_wrap(middleware, _make_request(state=dict(_A2UI_STATE), tools=[]))
middleware.after_agent(dict(_A2UI_STATE), MagicMock(name="runtime"))
received = _run_wrap_tool(
middleware, _make_tool_request("generate_a2ui", tool=None)
)
assert received.tool is None
# --- catalog sourced from wherever the frontend passed it ----------------
def test_injected_from_copilotkit_context(self):
"""CopilotKit runtime-proxy path: catalog arrives as a context entry
(the flag still gates injection)."""
middleware = CopilotKitMiddleware()
state = {
"messages": [],
"ag-ui": {"inject_a2ui_tool": True},
"copilotkit": {
"context": [
{
"description": "A2UI catalog capabilities: available "
"catalog IDs and custom component definitions.",
"value": "Available A2UI catalog:\n- my-custom-catalog\n"
" - Card: {...}\n - Metric: {...}",
}
]
},
}
request = _make_request(state=state, tools=[{"name": "backend"}])
seen, _ = _run_wrap(middleware, request)
names = [getattr(t, "name", None) or t.get("name") for t in seen.tools]
assert "generate_a2ui" in names
assert "backend" in names
def test_resolve_catalog_from_context_extracts_catalog_id(self):
schema, catalog_id = CopilotKitMiddleware._resolve_a2ui_catalog(
{
"copilotkit": {
"context": [
{
"description": "A2UI catalog capabilities",
"value": "Available A2UI catalog:\n- declarative-gen-ui-catalog\n ...",
}
]
}
}
)
assert catalog_id == "declarative-gen-ui-catalog"
assert schema and "declarative-gen-ui-catalog" in schema
def test_resolve_catalog_from_native_schema_extracts_catalog_id(self):
schema, catalog_id = CopilotKitMiddleware._resolve_a2ui_catalog(
{
"ag-ui": {
"a2ui_schema": json.dumps({"catalogId": "cat-x", "components": []})
}
}
)
# Native path: toolkit reads a2ui_schema from state, so no guide needed.
assert schema is None
assert catalog_id == "cat-x"
def test_resolve_catalog_none_when_absent(self):
assert CopilotKitMiddleware._resolve_a2ui_catalog({"messages": []}) is None
# --- A2UI injectA2UITool flag (forwarded → ag-ui state) ------------------
def test_inject_decision_reads_ag_ui_flag(self):
read = CopilotKitMiddleware._a2ui_inject_decision
assert read({"ag-ui": {"inject_a2ui_tool": True}}) is True
assert read({"ag-ui": {"inject_a2ui_tool": "render_x"}}) == "render_x"
assert read({"ag-ui": {"inject_a2ui_tool": False}}) is False
# No flag at all → None (opt-in: no injection).
assert read({"ag-ui": {}}) is None
assert read({"messages": []}) is None
def test_render_tool_dropped_when_ours_injected(self):
"""When we inject generate_a2ui, the runtime's render_a2ui (forwarded as
a frontend action) is not advertised — the model sees one A2UI tool."""
middleware = CopilotKitMiddleware()
state = {
**_A2UI_STATE,
"copilotkit": {
"actions": [{"name": "render_a2ui"}, {"name": "fe_tool"}],
},
}
request = _make_request(state=state, tools=[])
seen, _ = _run_wrap(middleware, request)
names = [getattr(t, "name", None) or t.get("name") for t in seen.tools]
assert "generate_a2ui" in names
assert "fe_tool" in names
assert "render_a2ui" not in names
def test_not_injected_when_agent_already_defines_tool(self):
"""Agent already exposes generate_a2ui → don't double-inject."""
middleware = CopilotKitMiddleware()
existing = MagicMock()
existing.name = "generate_a2ui"
request = _make_request(state=dict(_A2UI_STATE), tools=[existing])
seen, _ = _run_wrap(middleware, request)
names = [getattr(t, "name", None) or t.get("name") for t in seen.tools]
# Only the agent's own tool — no second generate_a2ui appended.
assert names.count("generate_a2ui") == 1