Skip to content

Commit 76efdb3

Browse files
Merge branch 'main' into fix/realtime-transcript-delta-history-updated
2 parents bbee339 + 1b7d878 commit 76efdb3

3 files changed

Lines changed: 410 additions & 1 deletion

File tree

tests/test_mcp_tool_metadata.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Unit tests for src/agents/_mcp_tool_metadata.py pure helpers.
2+
3+
The module resolves MCP tool display metadata (title / description) from
4+
either dict payloads or attribute-bearing objects. It feeds hosted-MCP
5+
tool definitions into the model and into traces, but had no direct
6+
test file.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass
12+
from typing import Any
13+
14+
from agents._mcp_tool_metadata import (
15+
MCPToolMetadata,
16+
collect_mcp_list_tools_metadata,
17+
extract_mcp_tool_metadata,
18+
resolve_mcp_tool_description,
19+
resolve_mcp_tool_description_for_model,
20+
resolve_mcp_tool_title,
21+
)
22+
23+
24+
@dataclass
25+
class _ToolObj:
26+
"""Tiny attribute-bearing stand-in for an MCP tool object."""
27+
28+
name: str | None = None
29+
title: str | None = None
30+
description: str | None = None
31+
annotations: Any = None
32+
33+
34+
@dataclass
35+
class _Annotations:
36+
title: str | None = None
37+
38+
39+
class TestResolveMCPToolTitle:
40+
def test_explicit_title_wins(self) -> None:
41+
tool = {"title": "Explicit", "annotations": {"title": "Annotated"}}
42+
assert resolve_mcp_tool_title(tool) == "Explicit"
43+
44+
def test_falls_back_to_annotations_title(self) -> None:
45+
tool = {"annotations": {"title": "Annotated"}}
46+
assert resolve_mcp_tool_title(tool) == "Annotated"
47+
48+
def test_returns_none_when_neither_present(self) -> None:
49+
assert resolve_mcp_tool_title({}) is None
50+
51+
def test_skips_empty_explicit_title(self) -> None:
52+
tool = {"title": "", "annotations": {"title": "Annotated"}}
53+
assert resolve_mcp_tool_title(tool) == "Annotated"
54+
55+
def test_skips_non_string_explicit_title(self) -> None:
56+
tool = {"title": 123, "annotations": {"title": "Annotated"}}
57+
assert resolve_mcp_tool_title(tool) == "Annotated"
58+
59+
def test_works_with_attribute_objects(self) -> None:
60+
tool = _ToolObj(title="Explicit")
61+
assert resolve_mcp_tool_title(tool) == "Explicit"
62+
63+
def test_works_with_attribute_annotations(self) -> None:
64+
tool = _ToolObj(annotations=_Annotations(title="Annotated"))
65+
assert resolve_mcp_tool_title(tool) == "Annotated"
66+
67+
def test_handles_missing_annotations_attribute(self) -> None:
68+
tool = _ToolObj()
69+
assert resolve_mcp_tool_title(tool) is None
70+
71+
72+
class TestResolveMCPToolDescription:
73+
def test_returns_description(self) -> None:
74+
assert resolve_mcp_tool_description({"description": "Long form"}) == "Long form"
75+
76+
def test_returns_none_when_empty(self) -> None:
77+
assert resolve_mcp_tool_description({"description": ""}) is None
78+
79+
def test_returns_none_when_missing(self) -> None:
80+
assert resolve_mcp_tool_description({}) is None
81+
82+
def test_returns_none_when_non_string(self) -> None:
83+
assert resolve_mcp_tool_description({"description": 123}) is None
84+
85+
def test_works_with_attribute_object(self) -> None:
86+
assert resolve_mcp_tool_description(_ToolObj(description="Long form")) == "Long form"
87+
88+
89+
class TestResolveMCPToolDescriptionForModel:
90+
def test_uses_description_when_present(self) -> None:
91+
tool = {"description": "Long form", "title": "Short"}
92+
assert resolve_mcp_tool_description_for_model(tool) == "Long form"
93+
94+
def test_falls_back_to_title_when_description_missing(self) -> None:
95+
assert resolve_mcp_tool_description_for_model({"title": "Short"}) == "Short"
96+
97+
def test_falls_back_to_annotations_title(self) -> None:
98+
tool = {"annotations": {"title": "Annotated"}}
99+
assert resolve_mcp_tool_description_for_model(tool) == "Annotated"
100+
101+
def test_returns_empty_string_when_nothing_resolvable(self) -> None:
102+
assert resolve_mcp_tool_description_for_model({}) == ""
103+
104+
105+
class TestExtractMCPToolMetadata:
106+
def test_collects_both_fields(self) -> None:
107+
tool = {"description": "Long form", "title": "Short"}
108+
assert extract_mcp_tool_metadata(tool) == MCPToolMetadata(
109+
description="Long form", title="Short"
110+
)
111+
112+
def test_returns_empty_metadata_when_nothing_present(self) -> None:
113+
assert extract_mcp_tool_metadata({}) == MCPToolMetadata()
114+
115+
116+
class TestCollectMCPListToolsMetadata:
117+
def test_collects_from_raw_payload(self) -> None:
118+
items = [
119+
{
120+
"type": "mcp_list_tools",
121+
"server_label": "github",
122+
"tools": [
123+
{"name": "search", "description": "Search repos", "title": "Search"},
124+
{"name": "create", "description": "Create issue"},
125+
],
126+
}
127+
]
128+
result = collect_mcp_list_tools_metadata(items)
129+
assert result == {
130+
("github", "search"): MCPToolMetadata(description="Search repos", title="Search"),
131+
("github", "create"): MCPToolMetadata(description="Create issue"),
132+
}
133+
134+
def test_unwraps_run_item_with_raw_item(self) -> None:
135+
@dataclass
136+
class _RunItem:
137+
raw_item: Any
138+
139+
run_item = _RunItem(
140+
raw_item={
141+
"type": "mcp_list_tools",
142+
"server_label": "internal",
143+
"tools": [{"name": "ping", "description": "Ping"}],
144+
}
145+
)
146+
result = collect_mcp_list_tools_metadata([run_item])
147+
assert result == {("internal", "ping"): MCPToolMetadata(description="Ping")}
148+
149+
def test_skips_items_without_correct_type(self) -> None:
150+
items = [
151+
{
152+
"type": "mcp_call",
153+
"server_label": "github",
154+
"tools": [{"name": "ignored"}],
155+
}
156+
]
157+
assert collect_mcp_list_tools_metadata(items) == {}
158+
159+
def test_skips_items_without_server_label(self) -> None:
160+
items = [
161+
{
162+
"type": "mcp_list_tools",
163+
"tools": [{"name": "search"}],
164+
}
165+
]
166+
assert collect_mcp_list_tools_metadata(items) == {}
167+
168+
def test_skips_items_with_non_list_tools(self) -> None:
169+
items = [
170+
{
171+
"type": "mcp_list_tools",
172+
"server_label": "github",
173+
"tools": "not-a-list",
174+
}
175+
]
176+
assert collect_mcp_list_tools_metadata(items) == {}
177+
178+
def test_skips_tools_without_name(self) -> None:
179+
items = [
180+
{
181+
"type": "mcp_list_tools",
182+
"server_label": "github",
183+
"tools": [
184+
{"description": "no name"},
185+
{"name": "", "description": "empty name"},
186+
{"name": "good", "description": "kept"},
187+
],
188+
}
189+
]
190+
result = collect_mcp_list_tools_metadata(items)
191+
assert result == {("github", "good"): MCPToolMetadata(description="kept")}
192+
193+
def test_returns_empty_for_empty_input(self) -> None:
194+
assert collect_mcp_list_tools_metadata([]) == {}
195+
196+
def test_later_entry_for_same_key_wins(self) -> None:
197+
items = [
198+
{
199+
"type": "mcp_list_tools",
200+
"server_label": "github",
201+
"tools": [{"name": "search", "description": "first"}],
202+
},
203+
{
204+
"type": "mcp_list_tools",
205+
"server_label": "github",
206+
"tools": [{"name": "search", "description": "second"}],
207+
},
208+
]
209+
result = collect_mcp_list_tools_metadata(items)
210+
assert result == {("github", "search"): MCPToolMetadata(description="second")}

tests/test_tool_identity.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Unit tests for src/agents/_tool_identity.py pure helpers.
2+
3+
These cover the small, pure functions in `_tool_identity` that build /
4+
parse function-tool lookup keys and trace names. The module had no
5+
direct test file even though it's imported across the runner, tracing,
6+
and tool-output trimmer code paths.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
13+
from agents._tool_identity import (
14+
deserialize_function_tool_lookup_key,
15+
get_function_tool_lookup_key,
16+
get_tool_call_name,
17+
get_tool_call_namespace,
18+
get_tool_call_qualified_name,
19+
get_tool_call_trace_name,
20+
is_reserved_synthetic_tool_namespace,
21+
serialize_function_tool_lookup_key,
22+
tool_qualified_name,
23+
tool_trace_name,
24+
)
25+
from agents.exceptions import UserError
26+
27+
28+
class TestToolQualifiedName:
29+
def test_returns_name_when_no_namespace(self) -> None:
30+
assert tool_qualified_name("search") == "search"
31+
32+
def test_returns_dotted_when_namespace_provided(self) -> None:
33+
assert tool_qualified_name("search", "tools") == "tools.search"
34+
35+
def test_returns_none_for_empty_name(self) -> None:
36+
assert tool_qualified_name("") is None
37+
assert tool_qualified_name(None) is None
38+
39+
def test_returns_none_for_non_string_name(self) -> None:
40+
assert tool_qualified_name(123) is None # type: ignore[arg-type]
41+
42+
def test_ignores_empty_namespace(self) -> None:
43+
assert tool_qualified_name("search", "") == "search"
44+
assert tool_qualified_name("search", None) == "search"
45+
46+
47+
class TestIsReservedSyntheticToolNamespace:
48+
def test_true_when_name_equals_namespace(self) -> None:
49+
assert is_reserved_synthetic_tool_namespace("search", "search") is True
50+
51+
def test_false_when_different(self) -> None:
52+
assert is_reserved_synthetic_tool_namespace("search", "tools") is False
53+
54+
def test_false_when_either_missing(self) -> None:
55+
assert is_reserved_synthetic_tool_namespace("", "") is False
56+
assert is_reserved_synthetic_tool_namespace("search", None) is False
57+
assert is_reserved_synthetic_tool_namespace(None, "search") is False
58+
59+
60+
class TestToolTraceName:
61+
def test_collapses_synthetic_namespace(self) -> None:
62+
# When namespace == name, trace name is just the bare name.
63+
assert tool_trace_name("search", "search") == "search"
64+
65+
def test_qualifies_real_namespace(self) -> None:
66+
assert tool_trace_name("search", "tools") == "tools.search"
67+
68+
def test_returns_bare_when_no_namespace(self) -> None:
69+
assert tool_trace_name("search", None) == "search"
70+
71+
72+
class TestToolCallExtractors:
73+
def test_get_tool_call_name_from_dict(self) -> None:
74+
assert get_tool_call_name({"name": "search"}) == "search"
75+
76+
def test_get_tool_call_name_from_object(self) -> None:
77+
class Call:
78+
name = "search"
79+
80+
assert get_tool_call_name(Call()) == "search"
81+
82+
def test_get_tool_call_name_returns_none_for_empty(self) -> None:
83+
assert get_tool_call_name({"name": ""}) is None
84+
assert get_tool_call_name({}) is None
85+
assert get_tool_call_name({"name": 123}) is None
86+
87+
def test_get_tool_call_namespace(self) -> None:
88+
assert get_tool_call_namespace({"namespace": "tools"}) == "tools"
89+
assert get_tool_call_namespace({"namespace": ""}) is None
90+
assert get_tool_call_namespace({}) is None
91+
92+
def test_get_tool_call_qualified_name_with_namespace(self) -> None:
93+
call = {"name": "search", "namespace": "tools"}
94+
assert get_tool_call_qualified_name(call) == "tools.search"
95+
96+
def test_get_tool_call_qualified_name_without_namespace(self) -> None:
97+
assert get_tool_call_qualified_name({"name": "search"}) == "search"
98+
99+
def test_get_tool_call_trace_name_collapses_synthetic_namespace(self) -> None:
100+
call = {"name": "search", "namespace": "search"}
101+
assert get_tool_call_trace_name(call) == "search"
102+
103+
def test_get_tool_call_trace_name_qualifies_real_namespace(self) -> None:
104+
call = {"name": "search", "namespace": "tools"}
105+
assert get_tool_call_trace_name(call) == "tools.search"
106+
107+
108+
class TestGetFunctionToolLookupKey:
109+
def test_bare_when_no_namespace(self) -> None:
110+
assert get_function_tool_lookup_key("search") == ("bare", "search")
111+
112+
def test_namespaced_when_namespace_present(self) -> None:
113+
assert get_function_tool_lookup_key("search", "tools") == (
114+
"namespaced",
115+
"tools",
116+
"search",
117+
)
118+
119+
def test_deferred_top_level_when_namespace_equals_name(self) -> None:
120+
assert get_function_tool_lookup_key("search", "search") == (
121+
"deferred_top_level",
122+
"search",
123+
)
124+
125+
def test_returns_none_for_empty_name(self) -> None:
126+
assert get_function_tool_lookup_key("") is None
127+
assert get_function_tool_lookup_key(None) is None
128+
129+
130+
class TestSerializeRoundTrip:
131+
@pytest.mark.parametrize(
132+
"lookup_key",
133+
[
134+
("bare", "search"),
135+
("namespaced", "tools", "search"),
136+
("deferred_top_level", "search"),
137+
],
138+
)
139+
def test_roundtrip(self, lookup_key) -> None:
140+
serialized = serialize_function_tool_lookup_key(lookup_key)
141+
assert serialized is not None
142+
assert deserialize_function_tool_lookup_key(serialized) == lookup_key
143+
144+
def test_serialize_none_returns_none(self) -> None:
145+
assert serialize_function_tool_lookup_key(None) is None
146+
147+
def test_deserialize_invalid_returns_none(self) -> None:
148+
assert deserialize_function_tool_lookup_key(None) is None
149+
assert deserialize_function_tool_lookup_key({}) is None
150+
assert deserialize_function_tool_lookup_key({"kind": "bare"}) is None
151+
assert deserialize_function_tool_lookup_key({"kind": "bare", "name": ""}) is None
152+
assert deserialize_function_tool_lookup_key({"kind": "unknown", "name": "x"}) is None
153+
# namespaced kind requires a non-empty namespace
154+
assert deserialize_function_tool_lookup_key({"kind": "namespaced", "name": "x"}) is None
155+
156+
157+
def test_validate_function_tool_namespace_shape_rejects_synthetic() -> None:
158+
"""The internal validator must refuse synthetic name==namespace shapes."""
159+
from agents._tool_identity import validate_function_tool_namespace_shape
160+
161+
# Valid shapes don't raise.
162+
validate_function_tool_namespace_shape("search", "tools")
163+
validate_function_tool_namespace_shape("search", None)
164+
165+
# The reserved synthetic shape (name == namespace) is rejected.
166+
with pytest.raises(UserError, match="reserves the synthetic namespace"):
167+
validate_function_tool_namespace_shape("search", "search")

0 commit comments

Comments
 (0)