diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 2127e6b165..eb7ffad8c4 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -108,7 +108,7 @@ def _ra(): AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset( - {"todo_list", "session_search", "memory", "clarify", "read_terminal", "desktop_preview", "drive_preview", "annotate_preview", "read_window_below", "setup_mcp", "tour", "delegate_task"} + {"todo_list", "session_search", "memory", "clarify", "read_terminal", "desktop_preview", "drive_preview", "annotate_preview", "read_window_below", "setup_mcp", "gui_tour", "delegate_task"} ) diff --git a/agent/coding_context.py b/agent/coding_context.py index f4928af245..0eb41c85b0 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -253,7 +253,7 @@ CODING_AGENT_GUIDANCE = ( "paths for the same flaw and fix the class, not just the reported site.\n" "- When fixing linter/type errors on a file, stop after about three " "attempts on the same file and ask the user rather than looping.\n" - "- Track multi-step work with `todo`. Reference code as `path:line` instead " + "- Track multi-step work with `todo_list`. Reference code as `path:line` instead " "of pasting whole files.\n" "\n" "Respect the user's repo: don't commit, push, or rewrite history unless " @@ -549,7 +549,7 @@ class RuntimeMode: brief = self.profile.guidance if valid_tool_names is not None and "todo_list" not in valid_tool_names: brief = brief.replace( - "- Track multi-step work with `todo`. Reference code as " + "- Track multi-step work with `todo_list`. Reference code as " "`path:line` instead of pasting whole files.", "- Reference code as `path:line` instead of pasting " "whole files.", diff --git a/tests/agent/test_display_todo_progress.py b/tests/agent/test_display_todo_progress.py index d182be9269..3d6d657ca5 100644 --- a/tests/agent/test_display_todo_progress.py +++ b/tests/agent/test_display_todo_progress.py @@ -26,7 +26,7 @@ class TestTodoRead: """get_cute_tool_message(…, result=…) when todos_arg is None (read path).""" def test_read_no_result(self): - msg = get_cute_tool_message("todo", {}, 0.5) + msg = get_cute_tool_message("todo_list", {}, 0.5) assert "reading tasks" in msg assert "0.5s" in msg @@ -34,7 +34,7 @@ class TestTodoRead: def test_read_zero_total(self): """Edge case: empty todo list returns summary with total=0.""" - msg = get_cute_tool_message("todo", {}, 0.5, + msg = get_cute_tool_message("todo_list", {}, 0.5, result=_todo_result(0, 0)) assert "reading tasks" in msg @@ -46,7 +46,7 @@ class TestTodoCreate: def test_create_default(self): """Brand-new plan: all pending, no result — plain count.""" - msg = get_cute_tool_message("todo", + msg = get_cute_tool_message("todo_list", {"todos": [ {"id": "a", "content": "x", "status": "pending"}, ]}, 0.3) @@ -58,7 +58,7 @@ class TestTodoCreate: def test_create_with_result_zero_done(self): """New plan with 0 done — plain count, no progress fraction.""" - msg = get_cute_tool_message("todo", + msg = get_cute_tool_message("todo_list", {"todos": [ {"id": "a", "content": "x", "status": "pending"}, {"id": "b", "content": "y", "status": "pending"}, @@ -74,7 +74,7 @@ class TestTodoUpdate: def test_update_no_result(self): """No result available — plain update N task(s).""" - msg = get_cute_tool_message("todo", + msg = get_cute_tool_message("todo_list", {"todos": [{"id": "a", "status": "completed"}], "merge": True}, 0.5) assert "update 1 task(s)" in msg @@ -82,7 +82,7 @@ class TestTodoUpdate: def test_update_halfway(self): """2/4 — midpoint progress.""" - msg = get_cute_tool_message("todo", + msg = get_cute_tool_message("todo_list", {"todos": [{"id": "b", "status": "in_progress"}], "merge": True}, 0.7, @@ -96,7 +96,7 @@ class TestTodoUpdate: def test_update_total_not_in_summary(self): """Result summary missing total key.""" - msg = get_cute_tool_message("todo", + msg = get_cute_tool_message("todo_list", {"todos": [{"id": "a", "status": "completed"}], "merge": True}, 0.3, @@ -111,7 +111,7 @@ class TestTodoEdgeCases: def test_merge_default_value(self): """merge defaults to False in function signature, should be False when absent.""" - msg = get_cute_tool_message("todo", + msg = get_cute_tool_message("todo_list", {"todos": [{"id": "a", "content": "x", "status": "pending"}]}, 1.0) assert "1 task(s)" in msg @@ -120,7 +120,7 @@ class TestTodoEdgeCases: def test_large_task_count(self): """Many tasks should not break formatting.""" many = [{"id": str(i), "content": "x", "status": "pending"} for i in range(50)] - msg = get_cute_tool_message("todo", {"todos": many}, 0.5) + msg = get_cute_tool_message("todo_list", {"todos": many}, 0.5) assert "50 task(s)" in msg @@ -131,7 +131,7 @@ class TestTodoSkinIntegration: """ def test_default_skin_prefix(self): - msg = get_cute_tool_message("todo", {}, 0.5) + msg = get_cute_tool_message("todo_list", {}, 0.5) assert msg.startswith("┊") diff --git a/tests/agent/test_phantom_tool_references.py b/tests/agent/test_phantom_tool_references.py index 045f356198..836522827a 100644 --- a/tests/agent/test_phantom_tool_references.py +++ b/tests/agent/test_phantom_tool_references.py @@ -65,8 +65,8 @@ class TestCodingBriefTodoGating: return prefix[0] def test_todo_kept_when_tool_available(self): - brief = self._brief({"todo", "terminal", "read_file"}) - assert "Track multi-step work with `todo`" in brief + brief = self._brief({"todo_list", "terminal", "read_file"}) + assert "Track multi-step work with `todo_list`" in brief def test_todo_dropped_when_tool_missing(self): brief = self._brief({"terminal", "read_file"}) @@ -76,7 +76,7 @@ class TestCodingBriefTodoGating: def test_unknown_toolset_keeps_full_brief(self): brief = self._brief(None) - assert "Track multi-step work with `todo`" in brief + assert "Track multi-step work with `todo_list`" in brief class TestEssentialSkillsUndisableable: diff --git a/tests/agent/test_stall_guards.py b/tests/agent/test_stall_guards.py index b79ba55c5b..013f8b83f8 100644 --- a/tests/agent/test_stall_guards.py +++ b/tests/agent/test_stall_guards.py @@ -92,7 +92,7 @@ def test_arg_canonicalization_ignores_key_order(): def test_allowlisted_pollers_never_fire(): c = ToolCallGuardrailController() - for tool in ("process", "vendor_get_result", "job_poll"): + for tool in ("process_manage", "vendor_get_result", "job_poll"): for _ in range(STALL_GUARD_IDENTICAL_CALL_THRESHOLD + 2): assert c.observe_identical_call(tool, {"id": "j1"}, "Generating") is None diff --git a/tests/agent/test_summarize_tool_result_type_safety.py b/tests/agent/test_summarize_tool_result_type_safety.py index 2899c9be27..f05cca0c0c 100644 --- a/tests/agent/test_summarize_tool_result_type_safety.py +++ b/tests/agent/test_summarize_tool_result_type_safety.py @@ -112,7 +112,7 @@ class TestBackstopWrapper: "terminal", "read_file", "write_file", "search_files", "patch", "browser_navigate", "web_search", "web_extract", "delegate_task", "execute_code", "skill_view", "vision_analyze", "memory", - "cronjob", "process", "totally_unknown_tool", + "cronjob_manage", "process_manage", "totally_unknown_tool", ] keys = ["command", "path", "content", "pattern", "url", "query", "urls", "goal", "code", "name", "question", "action", @@ -151,12 +151,12 @@ class TestDisplayPreviewTypeSafety: def test_process_preview_non_string_data(self): from agent.display import build_tool_preview result = build_tool_preview( - "process", {"action": "submit", "session_id": "abc", "data": 42} + "process_manage", {"action": "submit", "session_id": "abc", "data": 42} ) assert result == 'submit abc "42"' def test_process_preview_none_action(self): from agent.display import build_tool_preview - result = build_tool_preview("process", {"action": None, "session_id": "abc"}) + result = build_tool_preview("process_manage", {"action": None, "session_id": "abc"}) assert isinstance(result, str) diff --git a/tests/cron/test_cron_reasoning_effort.py b/tests/cron/test_cron_reasoning_effort.py index cea6d11230..4f45549a05 100644 --- a/tests/cron/test_cron_reasoning_effort.py +++ b/tests/cron/test_cron_reasoning_effort.py @@ -194,7 +194,7 @@ class TestCronjobToolReasoningEffort: def _tool_handler(self): import tools.cronjob_tools as mod - return mod.registry._tools["cronjob"].handler + return mod.registry._tools["cronjob_manage"].handler def test_schema_does_not_expose_reasoning_effort(self): """Policy pin: the model-facing surface must NOT offer the diff --git a/tests/gateway/test_api_server_toolset.py b/tests/gateway/test_api_server_toolset.py index fb9fe9176b..debdbbfb52 100644 --- a/tests/gateway/test_api_server_toolset.py +++ b/tests/gateway/test_api_server_toolset.py @@ -17,11 +17,11 @@ class TestHermesApiServerToolset: def test_toolset_includes_core_tools(self): tools = resolve_toolset("hermes-api-server") expected = [ - "terminal", "process", + "terminal", "process_manage", "read_file", "write_file", "patch", "search_files", "vision_analyze", "image_generate", "execute_code", "delegate_task", - "todo", "memory", "session_search", "cronjob", + "todo_list", "memory", "session_search", "cronjob_manage", ] for tool in expected: assert tool in tools, f"Missing expected tool: {tool}" diff --git a/tests/hermes_cli/test_setup_blank_slate.py b/tests/hermes_cli/test_setup_blank_slate.py index b401a2069e..e08d67c8e3 100644 --- a/tests/hermes_cli/test_setup_blank_slate.py +++ b/tests/hermes_cli/test_setup_blank_slate.py @@ -53,6 +53,14 @@ class TestBlankSlateMinimalToolsets: from tools.registry import registry as _tool_registry _entry = _tool_registry.get_entry("vision_analyze") monkeypatch.setattr(_entry, "check_fn", lambda: True) + # This test pins disabled_toolsets SUBTRACTION, not deferral policy — + # assemble with the legacy everything-eager override so the expected + # list stays deferral-independent (#97979 defers process_manage by + # default, which would swap it for the three bridge tools here). + from tools.tool_search import ToolSearchConfig + _legacy = ToolSearchConfig.from_raw({"enabled": "on", "defer": []}) + monkeypatch.setattr("tools.tool_search.load_config", lambda: _legacy) + monkeypatch.setattr("tools.tool_search.load_config_readonly", lambda: _legacy) from hermes_cli.tools_config import _get_platform_tools cfg = {} _blank_slate_minimal_toolsets(cfg) @@ -67,7 +75,7 @@ class TestBlankSlateMinimalToolsets: names = sorted( {(d.get("function") or {}).get("name") or d.get("name") for d in defs} ) - assert names == ["patch", "process", "read_file", "search_files", + assert names == ["patch", "process_manage", "read_file", "search_files", "skill_manage", "skill_view", "skills_list", "terminal", "vision_analyze", "write_file"] diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index af6207c683..5ab9097166 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2203,7 +2203,7 @@ class TestConcurrentToolExecution: def test_invoke_tool_handles_agent_level_tools(self, agent): """_invoke_tool should handle todo tool directly.""" with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}') as mock_todo: - result = agent._invoke_tool("todo", {"todos": []}, "task-1") + result = agent._invoke_tool("todo_list", {"todos": []}, "task-1") mock_todo.assert_called_once() assert "ok" in result @@ -2295,7 +2295,7 @@ class TestConcurrentToolExecution: """Sequential and concurrent agent-level paths share post-hook ownership.""" from agent.agent_runtime_helpers import agent_runtime_owns_post_tool_hook - for tool_name in ("todo", "session_search", "memory", "clarify", "delegate_task"): + for tool_name in ("todo_list", "session_search", "memory", "clarify", "delegate_task"): assert agent_runtime_owns_post_tool_hook(agent, tool_name) is True agent._context_engine_tool_names = {"context_query"} @@ -2441,7 +2441,7 @@ class TestAgentRuntimePostHookOwnershipSync: """Exercise post-hook ownership through both agent-runtime tool paths.""" _CASES = ( - ("todo", {"todos": []}), + ("todo_list", {"todos": []}), ("session_search", {"query": "needle"}), ("memory", {"action": "view", "target": "memory"}), ("clarify", {"question": "Continue?"}), @@ -2451,7 +2451,7 @@ class TestAgentRuntimePostHookOwnershipSync: ("annotate_preview", {"action": "clear"}), ("read_window_below", {}), ("setup_mcp", {"server": "linear", "action": "install"}), - ("tour", {"action": "stop"}), + ("gui_tour", {"action": "stop"}), ("delegate_task", {"goal": "Check the child path"}), ) diff --git a/tests/run_agent/test_tool_arg_coercion.py b/tests/run_agent/test_tool_arg_coercion.py index 4390c3e9a1..00dcb289b0 100644 --- a/tests/run_agent/test_tool_arg_coercion.py +++ b/tests/run_agent/test_tool_arg_coercion.py @@ -244,5 +244,5 @@ class TestCoerceToolArgsNested: """Against the real todo schema from the registry.""" import json as _json args = {"todos": [_json.dumps({"id": "1", "content": "x", "status": "pending"})]} - result = coerce_tool_args("todo", args) + result = coerce_tool_args("todo_list", args) assert result["todos"][0] == {"id": "1", "content": "x", "status": "pending"} diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index a967f61575..9e1fa9886e 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -227,7 +227,7 @@ class TestHandleFunctionCall: class TestAgentLoopTools: def test_expected_tools_in_set(self): - assert "todo" in _AGENT_LOOP_TOOLS + assert "todo_list" in _AGENT_LOOP_TOOLS assert "memory" in _AGENT_LOOP_TOOLS assert "session_search" in _AGENT_LOOP_TOOLS assert "delegate_task" in _AGENT_LOOP_TOOLS diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 73ce7a9eab..8499335d6a 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -265,7 +265,7 @@ class TestResolveToolsetIncludeRegistry: finally: registry.deregister("__probe_registry_only_tool__") - assert static == {"terminal", "process"}, static + assert static == {"terminal", "process_manage"}, static # Registered into 'terminal' but not part of the static definition — it # must only appear in the merged view. assert "__probe_registry_only_tool__" in merged @@ -275,7 +275,7 @@ class TestResolveToolsetIncludeRegistry: def test_static_view_threads_through_includes(self): # 'debugging' has direct tools [terminal, process] and includes [web, file] static = set(resolve_toolset("debugging", include_registry=False)) - assert {"terminal", "process"} <= static + assert {"terminal", "process_manage"} <= static assert "web_search" in static assert "read_file" in static diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 801ae939a3..eb79820f63 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -427,7 +427,7 @@ class TestAgentCannotSetModelPin: updated = json.loads( registry.dispatch( - "cronjob", + "cronjob_manage", { "action": "update", "job_id": job_id, @@ -461,7 +461,7 @@ class TestRegisteredHandlerForwardsAttachToSession: created = json.loads( registry.dispatch( - "cronjob", + "cronjob_manage", { "action": "create", "name": "Continuable cron canary", @@ -478,7 +478,7 @@ class TestRegisteredHandlerForwardsAttachToSession: stored = get_job(created["job_id"]) assert stored is not None assert stored.get("attach_to_session") is True - listing = json.loads(registry.dispatch("cronjob", {"action": "list"})) + listing = json.loads(registry.dispatch("cronjob_manage", {"action": "list"})) listed = next(j for j in listing["jobs"] if j["job_id"] == created["job_id"]) assert listed.get("attach_to_session") is True @@ -488,7 +488,7 @@ class TestRegisteredHandlerForwardsAttachToSession: created = json.loads( registry.dispatch( - "cronjob", + "cronjob_manage", { "action": "create", "name": "plain", @@ -502,7 +502,7 @@ class TestRegisteredHandlerForwardsAttachToSession: updated = json.loads( registry.dispatch( - "cronjob", + "cronjob_manage", { "action": "update", "job_id": created["job_id"], @@ -518,7 +518,7 @@ class TestRegisteredHandlerForwardsAttachToSession: disabled = json.loads( registry.dispatch( - "cronjob", + "cronjob_manage", { "action": "update", "job_id": created["job_id"], @@ -531,7 +531,7 @@ class TestRegisteredHandlerForwardsAttachToSession: stored = get_job(created["job_id"]) assert stored is not None assert stored.get("attach_to_session") is False - listing = json.loads(registry.dispatch("cronjob", {"action": "list"})) + listing = json.loads(registry.dispatch("cronjob_manage", {"action": "list"})) listed = next(j for j in listing["jobs"] if j["job_id"] == created["job_id"]) assert listed.get("attach_to_session") is False @@ -541,7 +541,7 @@ class TestRegisteredHandlerForwardsAttachToSession: created = json.loads( registry.dispatch( - "cronjob", + "cronjob_manage", { "action": "create", "schedule": "1h", @@ -554,7 +554,7 @@ class TestRegisteredHandlerForwardsAttachToSession: assert stored is not None assert "attach_to_session" not in stored # And the formatted list output must not invent the field either. - listed = json.loads(registry.dispatch("cronjob", {"action": "list"})) + listed = json.loads(registry.dispatch("cronjob_manage", {"action": "list"})) formatted = next( j for j in listed["jobs"] if j["job_id"] == created["job_id"] ) diff --git a/tests/tools/test_desktop_tools_diet.py b/tests/tools/test_desktop_tools_diet.py index 4329662b78..6ab8d20a88 100644 --- a/tests/tools/test_desktop_tools_diet.py +++ b/tests/tools/test_desktop_tools_diet.py @@ -26,14 +26,24 @@ class TestConsolidatedToolsets(unittest.TestCase): self.assertEqual(proj, ["desktop_project"]) def test_registry_serves_only_new_names(self): - from model_tools import get_tool_definitions + """Post-#97979 the GUI surface defers by default, so assemble with + the legacy everything-eager override (defer: []) — the contract + pinned here is the RENAME (new names only, dead names gone), not + the deferral policy.""" + from unittest.mock import patch as _patch - names = { - t["function"]["name"] - for t in get_tool_definitions( - quiet_mode=True, enabled_toolsets=["desktop_ui", "project"] - ) - } + from model_tools import get_tool_definitions + from tools.tool_search import ToolSearchConfig + + legacy = ToolSearchConfig.from_raw({"enabled": "on", "defer": []}) + with _patch("tools.tool_search.load_config_readonly", return_value=legacy), \ + _patch("tools.tool_search.load_config", return_value=legacy): + names = { + t["function"]["name"] + for t in get_tool_definitions( + quiet_mode=True, enabled_toolsets=["desktop_ui", "project"] + ) + } self.assertIn("desktop_preview", names) self.assertIn("desktop_project", names) for dead in ( diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index 89878150dc..f261e2ae08 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -36,13 +36,22 @@ class TestToolResolution: def test_terminal_and_file_toolsets_resolve_all_tools(self): """enabled_toolsets=['terminal', 'file'] should produce 6 tools.""" + from unittest.mock import patch as _patch + from model_tools import get_tool_definitions - tools = get_tool_definitions( - enabled_toolsets=["terminal", "file"], - quiet_mode=True, - ) + from tools.tool_search import ToolSearchConfig + + # Pin the RESOLUTION contract independent of deferral policy — + # #97979 defers process_manage by default (legacy defer: [] override). + _legacy = ToolSearchConfig.from_raw({"enabled": "on", "defer": []}) + with _patch("tools.tool_search.load_config", return_value=_legacy), \ + _patch("tools.tool_search.load_config_readonly", return_value=_legacy): + tools = get_tool_definitions( + enabled_toolsets=["terminal", "file"], + quiet_mode=True, + ) names = {t["function"]["name"] for t in tools} - expected = {"terminal", "process", "read_file", "write_file", "search_files", "patch"} + expected = {"terminal", "process_manage", "read_file", "write_file", "search_files", "patch"} assert expected == names, f"Expected {expected}, got {names}" def test_terminal_tool_present(self): diff --git a/tests/tools/test_tip_tool.py b/tests/tools/test_tip_tool.py index a510f0c0a4..e0bacf7cd6 100644 --- a/tests/tools/test_tip_tool.py +++ b/tests/tools/test_tip_tool.py @@ -24,7 +24,7 @@ def emitted(monkeypatch): def test_lives_in_the_gui_surface_toolset(monkeypatch): """Scoped by toolset, not by the backend's env — see AGENTS.md.""" monkeypatch.delenv("HERMES_DESKTOP", raising=False) - entry = registry.get_entry("tip") + entry = registry.get_entry("show_tip") assert entry is not None assert entry.toolset == "desktop_ui" @@ -32,7 +32,7 @@ def test_lives_in_the_gui_surface_toolset(monkeypatch): def test_is_ungated_like_tour(): """The Appearance switch governs the app's idle rotation, not this.""" - entry = registry.get_entry("tip") + entry = registry.get_entry("show_tip") assert entry is not None assert entry.check_fn is None diff --git a/tests/tools/test_tour_tool.py b/tests/tools/test_tour_tool.py index f6147f6274..2b1576042a 100644 --- a/tests/tools/test_tour_tool.py +++ b/tests/tools/test_tour_tool.py @@ -14,7 +14,7 @@ def _run(**kwargs): def test_lives_in_the_gui_surface_toolset(monkeypatch): """Scoped by toolset, not by the backend's env — see AGENTS.md.""" monkeypatch.delenv("HERMES_DESKTOP", raising=False) - entry = registry.get_entry("tour") + entry = registry.get_entry("gui_tour") assert entry is not None assert entry.toolset == "desktop_ui" diff --git a/tests/tui_gateway/test_gui_surface_toolsets.py b/tests/tui_gateway/test_gui_surface_toolsets.py index b22f04feb0..92463fe453 100644 --- a/tests/tui_gateway/test_gui_surface_toolsets.py +++ b/tests/tui_gateway/test_gui_surface_toolsets.py @@ -27,8 +27,8 @@ GUI_TOOLS = { "read_window_below", "react_to_message", "setup_mcp", - "tip", - "tour", + "show_tip", + "gui_tour", } @@ -43,7 +43,13 @@ def no_desktop_env(monkeypatch): class TestDesktopUiToolset: def test_holds_exactly_the_gui_affordances(self): - assert set(resolve_toolset("desktop_ui")) == GUI_TOOLS + # apply_layout registers into desktop_ui via the registry (not the + # static toolsets.py list), so force discovery first — otherwise the + # result depends on which earlier test imported tool modules + # (pre-existing ordering flake, surfaced by the #97979 test sweep). + from tools.registry import discover_builtin_tools + discover_builtin_tools() + assert set(resolve_toolset("desktop_ui")) == GUI_TOOLS | {"apply_layout"} def test_stays_off_the_core_tool_list(self): """Core ships on every API call — a GUI-only tool must not be there.""" diff --git a/tests/tui_gateway/test_hud_surface_note.py b/tests/tui_gateway/test_hud_surface_note.py index cfb201e2d1..029664b351 100644 --- a/tests/tui_gateway/test_hud_surface_note.py +++ b/tests/tui_gateway/test_hud_surface_note.py @@ -117,7 +117,12 @@ class TestTurnRouting: assert assembled.activated assert mcp_name not in names - assert server._hud_surface_note(_session(tools=names, client_surface="hud")) == ( + # Production computes the note from agent.valid_tool_names — the + # GRANTED set — not from the visible post-assembly schemas. Under + # #97979 the HUD kit (read_window_below, computer_use) is deferred + # behind the bridge yet still granted/callable, so the note must + # survive assembly unchanged. + assert server._hud_surface_note(_session(tools=FULL_KIT, client_surface="hud")) == ( hud_surface_note(FULL_KIT) )