mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
test: cover the image gate
The gate has four entry points and each now has a test: `view_image` and `fetch_local` refuse with the message naming the config key, the registry drops a plugin tool's picture and says why, and the workspace helpers keep `None` (no host verdict) permissive. On the CLI side the verdict is asserted to follow the selected model, and to reach the tools once an instance is bound.
This commit is contained in:
@@ -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()]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user