diff --git a/tests/test_agent/test_tool_output.py b/tests/test_agent/test_tool_output.py index 3788ffa..5031a57 100644 --- a/tests/test_agent/test_tool_output.py +++ b/tests/test_agent/test_tool_output.py @@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import ( ToolChatMessage, UserChatMessage, ) +from plyngent.tools.context import InstanceState def _image() -> ChatImagePart: @@ -100,3 +101,36 @@ def test_the_char_estimate_counts_images() -> None: assert estimate_message_chars(ToolChatMessage(content="shot", tool_call_id="call-1", images=[_image()])) == ( len("shot") + len("call-1") + IMAGE_CHARS ) + + +def _registry_for(instance: InstanceState) -> ToolRegistry: + @tool(register=False) + def shot() -> ToolOutput: + """Pretend to take a screenshot.""" + return ToolOutput(text="image: shot.png (2 KB)", images=[_image()]) + + return ToolRegistry([shot], instance_state=instance) + + +async def test_a_text_only_model_never_receives_a_plugin_tool_image() -> None: + """The registry is the safety net for tools that do not check ``image_in``.""" + instance = InstanceState() + instance.workspace.image_in = False + instance.workspace.image_model = ("ds", "text-only") + + output = await _registry_for(instance).execute_output("shot", "{}") + + assert output.text.startswith("image: shot.png (2 KB)") + assert "image dropped" in output.text + assert "image_in = true" in output.text + assert not output.has_images + + +async def test_a_vision_model_keeps_a_plugin_tool_image() -> None: + instance = InstanceState() + instance.workspace.image_in = True + + output = await _registry_for(instance).execute_output("shot", "{}") + + assert output.text == "image: shot.png (2 KB)" + assert output.images == [_image()] diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index 1425ea8..daaed7d 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -24,6 +24,8 @@ from plyngent.lmproto.openai_compatible.model import ( UserChatMessage, ) from plyngent.memory import MemoryStore +from plyngent.tools import image_input_refusal +from plyngent.tools.context import bind_instance if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -855,3 +857,23 @@ async def test_a_picture_mention_is_skipped_for_a_text_only_model( note = payload.notes[0] assert "does not accept image input" in note assert "image_in = true" in note + + +async def test_the_tool_image_gate_follows_the_selected_model(state: ReplState) -> None: + """The image-carrying tools read the same verdict the mention gate uses.""" + policy = state.instance_state.workspace + + state.sync_image_capability() + assert policy.image_in is False + assert policy.image_model == (state.provider_name, state.model) + + state.provider.models[state.model] = ModelConfig(image_in=True) + state.sync_image_capability() + assert policy.image_in is True + + state.provider.models[state.model] = ModelConfig(image_in=False) + state.sync_image_capability() + with bind_instance(state.instance_state): + refusal = image_input_refusal() + assert refusal is not None + assert f'[providers.{state.provider_name}.models."{state.model}"]' in refusal diff --git a/tests/test_tools/test_fetch_local.py b/tests/test_tools/test_fetch_local.py index 7a4d1b7..721e645 100644 --- a/tests/test_tools/test_fetch_local.py +++ b/tests/test_tools/test_fetch_local.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, override import pytest from plyngent.agent import ToolOutput -from plyngent.tools.context import read_only_context +from plyngent.tools.context import get_instance, read_only_context from plyngent.tools.danger import classify_danger from plyngent.tools.net import fetch_local, grant_private_host from plyngent.tools.net.policy import ( @@ -225,6 +225,24 @@ async def test_an_image_response_comes_back_as_the_picture(workspace: Path, http assert part.image_url.url.startswith("data:image/png;base64,") +async def test_a_text_only_model_keeps_the_metadata_and_the_reason( + workspace: Path, + http_server: str, +) -> None: + """The picture stays behind, and the model is told why (``image_in``).""" + grant_private_host("127.0.0.1", int(http_server.rsplit(":", 1)[1])) + instance = get_instance() + assert instance is not None + instance.workspace.image_in = False + instance.workspace.image_model = ("ds", "text-only") + + output = await fetch_local.handler(f"{http_server}/png") + + assert isinstance(output, str) + assert "body_kind=image" in output + assert "error: model 'text-only' does not accept image input" in output + + async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None: """In a read-only context fetch_local only allows GET; outside it POST works.""" del workspace diff --git a/tests/test_tools/test_view_image.py b/tests/test_tools/test_view_image.py index f8938f7..95527fc 100644 --- a/tests/test_tools/test_view_image.py +++ b/tests/test_tools/test_view_image.py @@ -6,6 +6,7 @@ import base64 from typing import TYPE_CHECKING from plyngent.agent import ToolOutput +from plyngent.tools.context import get_instance from plyngent.tools.file.read import read_file from plyngent.tools.file.view_image import view_image @@ -60,6 +61,21 @@ async def test_a_missing_file_is_an_error(workspace: Path) -> None: assert await view_image.handler("nope.png") == "error: file not found: nope.png" +async def test_a_text_only_model_gets_the_reason_instead_of_the_picture(workspace: Path) -> None: + """``image_in`` gates the tool exactly like an ``@path`` mention.""" + (workspace / "shot.png").write_bytes(PNG) + instance = get_instance() + assert instance is not None + instance.workspace.image_in = False + instance.workspace.image_model = ("deepseek", "deepseek-v4-pro") + + result = await view_image.handler("shot.png") + + assert isinstance(result, str) + assert result.startswith("error: model 'deepseek-v4-pro' does not accept image input") + assert 'set image_in = true under [providers.deepseek.models."deepseek-v4-pro"]' in result + + async def test_read_file_points_at_view_image_for_a_picture(workspace: Path) -> None: (workspace / "shot.png").write_bytes(PNG) diff --git a/tests/test_tools/test_workspace.py b/tests/test_tools/test_workspace.py index 90c139d..88e1c89 100644 --- a/tests/test_tools/test_workspace.py +++ b/tests/test_tools/test_workspace.py @@ -9,10 +9,12 @@ from plyngent.tools import ( check_command_allowed, get_trash_enabled, get_workspace_root, + image_input_refusal, mode_covers, parse_access_mode, resolve_path, set_command_denylist, + set_image_in, set_path_denylist, set_trash_enabled, ) @@ -67,6 +69,34 @@ def test_trash_switch(workspace: object) -> None: assert get_trash_enabled() is True +def test_image_switch(workspace: object) -> None: + """A picture is refused once the host says the model cannot take one.""" + del workspace + assert image_input_refusal() is None + + set_image_in(image_in=False) + unnamed = image_input_refusal() + assert unnamed is not None + assert "does not accept image input" in unnamed + + set_image_in(image_in=False, provider="ds", model="text-only") + named = image_input_refusal() + assert named is not None + assert "model 'text-only' does not accept image input" in named + assert '[providers.ds.models."text-only"]' in named + + set_image_in(image_in=True, provider="ds", model="vision") + assert image_input_refusal() is None + + +def test_the_image_switch_needs_a_declaration(workspace: object) -> None: + """No host verdict (``None``) sends the picture: only ``False`` refuses.""" + del workspace + policy = active_workspace_policy() + assert policy.image_in is None + assert image_input_refusal() is None + + def test_config_grant_modes(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None: del workspace outside = tmp_path_factory.mktemp("granted")