mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
37e9395ac5
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.
880 lines
31 KiB
Python
880 lines
31 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
from typing import TYPE_CHECKING, Literal, overload
|
|
|
|
import pytest
|
|
import tomlkit
|
|
|
|
from plyngent.agent import ChatAgent, Notice
|
|
from plyngent.cli.mentions import resolve_mentions
|
|
from plyngent.cli.repl import _send_turn
|
|
from plyngent.cli.slash import handle_slash
|
|
from plyngent.cli.state import ReplState
|
|
from plyngent.config.models import DatabaseConfig, ModelConfig, OpenAIProvider
|
|
from plyngent.config.store import ConfigStore
|
|
from plyngent.lmproto.openai_compatible.model import (
|
|
AssistantChatMessage,
|
|
ChatCompletionChoice,
|
|
ChatCompletionChunk,
|
|
ChatCompletionResponse,
|
|
ChatCompletionsParam,
|
|
ChatImagePart,
|
|
ChatTextPart,
|
|
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
|
|
from pathlib import Path
|
|
|
|
|
|
# A real 1x1 PNG: ``/image`` sniffs the format from these bytes.
|
|
_PNG = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
def _accept_images(state: ReplState) -> None:
|
|
"""Mark the fixture's model as taking pictures (the config default is text-only)."""
|
|
state.provider.models[state.model] = ModelConfig(image_in=True)
|
|
|
|
|
|
class DummyClient:
|
|
@overload
|
|
async def chat_completions(
|
|
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
|
) -> ChatCompletionResponse: ...
|
|
|
|
@overload
|
|
async def chat_completions(
|
|
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
|
) -> AsyncIterator[ChatCompletionChunk]: ...
|
|
|
|
async def chat_completions(
|
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
|
del param
|
|
if stream:
|
|
|
|
async def empty() -> AsyncIterator[ChatCompletionChunk]:
|
|
empty_chunks: list[ChatCompletionChunk] = []
|
|
for chunk in empty_chunks:
|
|
yield chunk
|
|
|
|
return empty()
|
|
return ChatCompletionResponse(
|
|
id="1",
|
|
object="chat.completion",
|
|
created=0,
|
|
model="m",
|
|
choices=[
|
|
ChatCompletionChoice(
|
|
index=0,
|
|
message=AssistantChatMessage(content="ok"),
|
|
logprobs={},
|
|
finish_reason="stop",
|
|
)
|
|
],
|
|
system_fingerprint="",
|
|
usage={},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
|
memory = await MemoryStore.open(DatabaseConfig())
|
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
|
config.providers = {"local": provider}
|
|
st = ReplState(
|
|
config=config,
|
|
memory=memory,
|
|
workspace=tmp_path,
|
|
provider_name="local",
|
|
provider=provider,
|
|
model="gpt-test",
|
|
tools_enabled=False,
|
|
)
|
|
st.client = DummyClient()
|
|
st.agent = ChatAgent(st.client, model=st.model, memory=st.memory, session_id=None)
|
|
await st.new_session("t")
|
|
yield st
|
|
await memory.close()
|
|
|
|
|
|
async def test_help(state: ReplState) -> None:
|
|
assert await handle_slash(state, "/help") is True
|
|
|
|
|
|
async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None:
|
|
from plyngent.cli.limits import auto_continue_enabled, reset_auto_continue
|
|
|
|
memory = await MemoryStore.open(DatabaseConfig())
|
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
|
config.providers = {"local": provider}
|
|
try:
|
|
st = ReplState(
|
|
config=config,
|
|
memory=memory,
|
|
workspace=tmp_path,
|
|
provider_name="local",
|
|
provider=provider,
|
|
model="gpt-test",
|
|
tools_enabled=False,
|
|
interactive_limits=False,
|
|
auto_continue_limits=True,
|
|
)
|
|
assert auto_continue_enabled() is True
|
|
assert st.agent.on_limit is not None
|
|
finally:
|
|
reset_auto_continue()
|
|
await memory.close()
|
|
|
|
|
|
async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert await handle_slash(state, "/help compact") is True
|
|
out = capsys.readouterr().out
|
|
assert "Usage: /compact" in out
|
|
assert "help [COMMAND] compact" not in out
|
|
assert "Soft-compact" in out
|
|
assert "--help" not in out
|
|
assert "Options:" not in out
|
|
|
|
|
|
async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert await handle_slash(state, "/help history") is True
|
|
out = capsys.readouterr().out
|
|
assert "Usage: /history" in out
|
|
# Real flags are documented; Click's meta --help is not.
|
|
assert "--verbose" in out
|
|
assert "--preview" in out
|
|
assert " --help" not in out
|
|
|
|
|
|
async def test_help_stream_clearer(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert await handle_slash(state, "/help stream") is True
|
|
out = capsys.readouterr().out
|
|
assert "Usage: /stream [on|off]" in out
|
|
assert "ENABLED" not in out
|
|
assert "tokens arrive" in out
|
|
assert "default" in out
|
|
|
|
|
|
async def test_history_rejects_help_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert await handle_slash(state, "/history --help") is True
|
|
captured = capsys.readouterr()
|
|
combined = captured.out + captured.err
|
|
assert "No such option" in combined
|
|
|
|
|
|
async def test_quit(state: ReplState) -> None:
|
|
assert await handle_slash(state, "/quit") is False
|
|
|
|
|
|
async def test_edit_sets_pending(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"plyngent.cli.editor.edit_text_in_editor",
|
|
lambda *a, **k: "composed message",
|
|
)
|
|
assert await handle_slash(state, "/edit") is True
|
|
assert state.pending_user_text == "composed message"
|
|
assert "characters ready" in capsys.readouterr().out
|
|
|
|
|
|
async def test_new_and_sessions(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
first = state.session_id
|
|
assert await handle_slash(state, "/new other") is True
|
|
assert state.session_id != first
|
|
assert await handle_slash(state, "/sessions") is True
|
|
out = capsys.readouterr().out
|
|
assert str(state.session_id) in out
|
|
|
|
|
|
async def test_tools_toggle(state: ReplState) -> None:
|
|
assert await handle_slash(state, "/tools on") is True
|
|
assert state.tools_enabled is True
|
|
assert await handle_slash(state, "/tools off") is True
|
|
assert state.tools_enabled is False
|
|
|
|
|
|
async def test_tools_list(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert await handle_slash(state, "/tools --list") is True
|
|
out_off = capsys.readouterr().out
|
|
assert "tools=off" in out_off or "no registry" in out_off
|
|
|
|
assert await handle_slash(state, "/tools on") is True
|
|
assert state.tools_enabled is True
|
|
assert state.agent.tools is not None
|
|
_ = capsys.readouterr()
|
|
assert await handle_slash(state, "/tools --list") is True
|
|
out = capsys.readouterr().out
|
|
assert "tools=on count=" in out
|
|
assert "read_file" in out
|
|
assert "LOCAL" in out
|
|
assert "builtin" in out
|
|
|
|
|
|
async def test_rename_slash(state: ReplState) -> None:
|
|
sid = state.session_id
|
|
assert sid is not None
|
|
assert await handle_slash(state, "/rename my-chat") is True
|
|
row = await state.memory.get_session(sid)
|
|
assert row is not None
|
|
assert row.name == "my-chat"
|
|
|
|
|
|
async def test_config_slash_reloads(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
path = state.config.path
|
|
assert path is not None
|
|
# Fixture ConfigStore may not have written the file yet.
|
|
state.config.write()
|
|
|
|
def fake_open(p: object, **_k: object) -> None:
|
|
assert str(p) == str(path)
|
|
|
|
monkeypatch.setattr("plyngent.cli.editor.open_in_editor", fake_open)
|
|
assert await handle_slash(state, "/config") is True
|
|
out = capsys.readouterr().out
|
|
assert "config reloaded" in out
|
|
assert state.provider_name
|
|
|
|
|
|
async def test_model_switch_persists(state: ReplState) -> None:
|
|
from plyngent.config.models import ModelConfig
|
|
|
|
state.provider.models = {
|
|
"m1": ModelConfig(),
|
|
"m2": ModelConfig(),
|
|
}
|
|
state.model = "m1"
|
|
await state.persist_llm_selection()
|
|
assert await handle_slash(state, "/model m2") is True
|
|
assert state.model == "m2"
|
|
assert state.session_id is not None
|
|
row = await state.memory.get_session(state.session_id)
|
|
assert row is not None
|
|
assert row.model == "m2"
|
|
|
|
|
|
async def test_provider_switch_prompts_when_model_missing(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
|
|
|
|
state.config.providers = {
|
|
"a": OpenAIProvider(
|
|
access_key_or_token="sk",
|
|
models={"shared": ModelConfig(), "only-a": ModelConfig()},
|
|
),
|
|
"b": OpenAICompatibleProvider(
|
|
access_key_or_token="sk",
|
|
url="https://x/v1",
|
|
models={"shared": ModelConfig(), "only-b": ModelConfig()},
|
|
),
|
|
}
|
|
state.provider_name = "a"
|
|
state.provider = state.config.providers["a"]
|
|
state.model = "only-a"
|
|
state.rebuild_client()
|
|
|
|
# No real GET /models — catalog is config-only for unit tests.
|
|
async def _no_remote(*, refresh: bool = False) -> list[str]:
|
|
del refresh
|
|
return []
|
|
|
|
monkeypatch.setattr(state, "ensure_remote_models", _no_remote)
|
|
|
|
# When switching to b, only-a is missing → select_model is invoked interactively.
|
|
monkeypatch.setattr(
|
|
"plyngent.cli.slash.select_model",
|
|
lambda provider, preferred=None, interactive=True, choices=None: "only-b",
|
|
)
|
|
assert await handle_slash(state, "/provider b") is True
|
|
assert state.provider_name == "b"
|
|
assert state.model == "only-b"
|
|
sid = state.session_id
|
|
assert sid is not None
|
|
row = await state.memory.get_session(sid)
|
|
assert row is not None
|
|
assert row.provider_name == "b"
|
|
assert row.model == "only-b"
|
|
|
|
|
|
async def test_provider_switch_keeps_shared_model(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
|
|
|
|
state.config.providers = {
|
|
"a": OpenAIProvider(
|
|
access_key_or_token="sk",
|
|
models={"shared": ModelConfig()},
|
|
),
|
|
"b": OpenAICompatibleProvider(
|
|
access_key_or_token="sk",
|
|
url="https://x/v1",
|
|
models={"shared": ModelConfig()},
|
|
),
|
|
}
|
|
state.provider_name = "a"
|
|
state.provider = state.config.providers["a"]
|
|
state.model = "shared"
|
|
state.rebuild_client()
|
|
|
|
async def _no_remote(*, refresh: bool = False) -> list[str]:
|
|
del refresh
|
|
return []
|
|
|
|
monkeypatch.setattr(state, "ensure_remote_models", _no_remote)
|
|
assert await handle_slash(state, "/provider b") is True
|
|
assert state.provider_name == "b"
|
|
assert state.model == "shared"
|
|
|
|
|
|
async def test_delete_slash_confirm(
|
|
state: ReplState,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
from plyngent.prompting import temporary_backend
|
|
from tests.test_prompting import ScriptedBackend
|
|
|
|
# Delete a non-current session so SQLite cannot reuse the same sid as "current".
|
|
victim = state.session_id
|
|
assert victim is not None
|
|
assert await handle_slash(state, "/new keep") is True
|
|
current = state.session_id
|
|
assert current != victim
|
|
|
|
with temporary_backend(ScriptedBackend([], confirms=[False])):
|
|
assert await handle_slash(state, f"/delete {victim}") is True
|
|
assert await state.memory.get_session(victim) is not None
|
|
assert "cancelled" in capsys.readouterr().out
|
|
|
|
with temporary_backend(ScriptedBackend([], confirms=[True])):
|
|
assert await handle_slash(state, f"/delete {victim}") is True
|
|
assert await state.memory.get_session(victim) is None
|
|
assert state.session_id == current
|
|
out = capsys.readouterr().out
|
|
assert "deleted" in out
|
|
assert "new session" not in out
|
|
|
|
|
|
async def test_export_slash(state: ReplState, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
|
|
|
assert state.session_id is not None
|
|
_ = await state.memory.append_message(state.session_id, UserChatMessage(content="hi"))
|
|
_ = await state.memory.append_message(state.session_id, AssistantChatMessage(content="yo"))
|
|
path = tmp_path / "out.md"
|
|
assert await handle_slash(state, f"/export md {path}") is True
|
|
text = path.read_text(encoding="utf-8")
|
|
assert "Session" in text
|
|
assert "hi" in text
|
|
assert "yo" in text
|
|
assert str(path.resolve()) in capsys.readouterr().out
|
|
|
|
jpath = tmp_path / "out.json"
|
|
assert await handle_slash(state, f"/export json {jpath}") is True
|
|
raw = jpath.read_text(encoding="utf-8")
|
|
assert '"session_id"' in raw
|
|
assert "hi" in raw
|
|
|
|
|
|
async def test_stream_toggle(state: ReplState) -> None:
|
|
assert state.agent.stream is True
|
|
assert await handle_slash(state, "/stream off") is True
|
|
assert state.agent.stream is False
|
|
assert state.stream_enabled is False
|
|
assert await handle_slash(state, "/stream on") is True
|
|
assert state.agent.stream is True
|
|
|
|
|
|
async def test_verbose_toggle(state: ReplState) -> None:
|
|
from plyngent.cli.display import get_verbose_tool_results
|
|
|
|
assert state.verbose is False
|
|
assert await handle_slash(state, "/verbose on") is True
|
|
assert state.verbose is True
|
|
assert get_verbose_tool_results() is True
|
|
assert await handle_slash(state, "/verbose off") is True
|
|
assert state.verbose is False
|
|
assert get_verbose_tool_results() is False
|
|
|
|
|
|
async def test_model_persist_writes_config(
|
|
state: ReplState, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
# Seed a TOML-backed store so write() has a real path.
|
|
path = tmp_path / "plyngent.toml"
|
|
_ = path.write_text(
|
|
"""
|
|
[providers.local]
|
|
preset = "openai"
|
|
access_key_or_token = "sk-test"
|
|
|
|
[providers.local.models.gpt-test]
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
from plyngent.config import load as load_config
|
|
|
|
state.config = load_config(path)
|
|
state.provider_name = "local"
|
|
state.provider = state.config.providers["local"]
|
|
state.model = "gpt-new-id"
|
|
assert await handle_slash(state, "/model --persist") is True
|
|
out = capsys.readouterr().out
|
|
assert "persisted model" in out
|
|
assert "gpt-new-id" in out
|
|
state.config.reload()
|
|
assert "gpt-new-id" in state.config.providers["local"].models
|
|
assert "gpt-test" in state.config.providers["local"].models
|
|
|
|
|
|
async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert state.effective_yolo() == "off"
|
|
assert state.soft_confirm_enabled() is True
|
|
assert await handle_slash(state, "/yolo") is True
|
|
assert "yolo=off" in capsys.readouterr().out
|
|
|
|
assert await handle_slash(state, "/yolo on") is True
|
|
assert state.effective_yolo() == "on"
|
|
assert state.soft_confirm_enabled() is False
|
|
assert "yolo=on" in capsys.readouterr().out
|
|
|
|
assert await handle_slash(state, "/yolo once") is True
|
|
assert state.effective_yolo() == "once"
|
|
assert state.soft_confirm_enabled() is False
|
|
|
|
state.expire_yolo_once(quiet=True)
|
|
assert state.effective_yolo() == "off"
|
|
assert state.soft_confirm_enabled() is True
|
|
|
|
assert await handle_slash(state, "/yolo once") is True
|
|
state.expire_yolo_once()
|
|
err = capsys.readouterr().err
|
|
assert "yolo=off (once expired)" in err
|
|
assert state.effective_yolo() == "off"
|
|
|
|
|
|
async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
|
|
memory = await MemoryStore.open(DatabaseConfig())
|
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
|
config.providers = {"local": provider}
|
|
st = ReplState(
|
|
config=config,
|
|
memory=memory,
|
|
workspace=tmp_path,
|
|
provider_name="local",
|
|
provider=provider,
|
|
model="gpt-test",
|
|
tools_enabled=True,
|
|
)
|
|
try:
|
|
assert st.agent.tools is not None
|
|
# Soft-confirm hooks stay installed; YOLO mode only auto-approves the
|
|
# confirms of YOLO-tagged tools, so the others still prompt.
|
|
assert st.agent.tools.soft_confirm is True
|
|
assert st.agent.tools.yolo is False
|
|
st.set_yolo("on")
|
|
assert st.agent.tools is not None
|
|
assert st.agent.tools.soft_confirm is True
|
|
assert st.agent.tools.yolo is True
|
|
st.set_yolo("once")
|
|
assert st.agent.tools is not None
|
|
assert st.agent.tools.yolo is True
|
|
st.set_yolo("off")
|
|
assert st.agent.tools is not None
|
|
assert st.agent.tools.soft_confirm is True
|
|
assert st.agent.tools.yolo is False
|
|
finally:
|
|
await memory.close()
|
|
|
|
|
|
async def test_resume(state: ReplState) -> None:
|
|
sid = state.session_id
|
|
assert sid is not None
|
|
state.agent.messages.clear()
|
|
assert await handle_slash(state, f"/resume {sid}") is True
|
|
|
|
|
|
async def test_rounds(state: ReplState) -> None:
|
|
assert await handle_slash(state, "/rounds 40") is True
|
|
assert state.max_rounds == 40
|
|
assert state.agent.max_rounds == 40
|
|
|
|
|
|
async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
from plyngent.agent.usage import TokenUsage
|
|
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
|
|
|
state.agent.messages = [UserChatMessage(content="hello")]
|
|
assert await handle_slash(state, "/status") is True
|
|
out = capsys.readouterr().out
|
|
assert "context_tokens=" in out
|
|
assert "(est)" in out # no API usage yet
|
|
assert "context_chars=" in out
|
|
assert "tool_result_max=" in out
|
|
assert "yolo=off" in out
|
|
assert str(state.workspace) in out
|
|
|
|
state.agent.last_request_usage = TokenUsage(
|
|
prompt_tokens=1234,
|
|
completion_tokens=10,
|
|
total_tokens=1244,
|
|
source="api",
|
|
)
|
|
assert await handle_slash(state, "/status") is True
|
|
out2 = capsys.readouterr().out
|
|
assert "context_tokens=1234/" in out2
|
|
assert "(api)" in out2
|
|
|
|
|
|
async def test_repl_ctrl_c_from_turn_returns_to_prompt(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A Ctrl+C escaping a turn lands back at the prompt, not out of the REPL."""
|
|
from plyngent.cli import repl as repl_mod
|
|
from plyngent.cli.repl import run_repl
|
|
|
|
calls: dict[str, int] = {"reads": 0}
|
|
|
|
def fake_read_entry() -> str:
|
|
calls["reads"] += 1
|
|
if calls["reads"] >= 2:
|
|
raise EOFError
|
|
return "hello"
|
|
|
|
async def interrupted_turn(_agent: object, _text: str, **_kwargs: object) -> bool:
|
|
raise KeyboardInterrupt
|
|
|
|
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
|
|
monkeypatch.setattr(repl_mod, "read_repl_entry", fake_read_entry)
|
|
monkeypatch.setattr(repl_mod, "run_user_text_with_retries", interrupted_turn)
|
|
|
|
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
|
|
assert calls["reads"] == 2
|
|
|
|
|
|
async def test_repl_second_ctrl_c_after_cancel_returns_to_prompt(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""A second Ctrl+C landing outside the turn call (e.g. while echoing the
|
|
next user input, right after a cancel) must return to the prompt, not exit."""
|
|
from plyngent.cli import repl as repl_mod
|
|
from plyngent.cli.repl import run_repl
|
|
|
|
reads = ["first", "second", "third"]
|
|
calls: dict[str, int] = {"reads": 0, "echoes": 0}
|
|
|
|
def fake_read_entry() -> str:
|
|
calls["reads"] += 1
|
|
if calls["reads"] > len(reads):
|
|
raise EOFError
|
|
return reads[calls["reads"] - 1]
|
|
|
|
async def cancel_turn(_agent: object, _text: str, **_kwargs: object) -> bool:
|
|
raise KeyboardInterrupt # first Ctrl+C: cancels every turn
|
|
|
|
def echo_user(text: str) -> None:
|
|
calls["echoes"] += 1
|
|
if calls["echoes"] == 2:
|
|
# second Ctrl+C lands while echoing user text — outside the turn
|
|
# call, previously unguarded (used to exit the whole REPL).
|
|
raise KeyboardInterrupt
|
|
|
|
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
|
|
monkeypatch.setattr(repl_mod, "read_repl_entry", fake_read_entry)
|
|
monkeypatch.setattr(repl_mod, "run_user_text_with_retries", cancel_turn)
|
|
monkeypatch.setattr(repl_mod, "_echo_user", echo_user)
|
|
|
|
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
|
|
assert calls["reads"] == 4 # three entries + EOF
|
|
assert calls["echoes"] == 3 # the interrupting echo still counted, loop survived
|
|
assert "interrupted" in capsys.readouterr().out
|
|
|
|
|
|
async def test_btw_default_read_falls_back_when_tools_off(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Bare /btw (default --tools=read) still works under /tools off: no tools."""
|
|
assert state.tools_enabled is False
|
|
state.agent.stream = False # DummyClient only completes via non-stream path
|
|
assert await handle_slash(state, "/btw hello") is True
|
|
out = capsys.readouterr().out
|
|
assert "btw: hello" in out
|
|
assert "btw done" in out
|
|
assert "error" not in out
|
|
|
|
|
|
async def test_btw_explicit_tools_errors_when_off(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Explicitly requesting read/full tools while they are off is an error."""
|
|
state.agent.stream = False # DummyClient only completes via non-stream path
|
|
assert await handle_slash(state, "/btw --tools read q") is True
|
|
assert "error: --tools requires tools on" in capsys.readouterr().out
|
|
assert await handle_slash(state, "/btw --tools full q") is True
|
|
assert "error: --tools requires tools on" in capsys.readouterr().out
|
|
# Explicit no is fine.
|
|
assert await handle_slash(state, "/btw --tools no q") is True
|
|
out = capsys.readouterr().out
|
|
assert "btw done" in out
|
|
assert "error" not in out
|
|
|
|
|
|
async def test_btw_read_mode_wires_read_only_clone(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""--tools=read passes a read-only clone + read-only context + main session."""
|
|
|
|
from plyngent.agent import ToolRegistry, ToolTag, tool
|
|
from plyngent.tools.context import is_read_only_context
|
|
|
|
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
|
|
async def ro_tool() -> str:
|
|
return "ro"
|
|
|
|
@tool(tags=ToolTag.LOCAL, register=False)
|
|
async def w_tool() -> str:
|
|
return "w"
|
|
|
|
state.tools_enabled = True
|
|
state.agent.tools = ToolRegistry([ro_tool, w_tool], auto_bind_state=True)
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
|
|
captured["tools"] = kwargs.get("tools")
|
|
captured["session_state"] = kwargs.get("session_state")
|
|
captured["read_only"] = is_read_only_context()
|
|
if False:
|
|
yield None
|
|
|
|
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
|
|
|
|
assert await handle_slash(state, "/btw --tools read hi") is True
|
|
assert "btw done" in capsys.readouterr().out
|
|
|
|
tools = captured["tools"]
|
|
assert isinstance(tools, ToolRegistry)
|
|
assert {t.name for t in tools.definitions()} == {"ro_tool"}
|
|
assert captured["session_state"] is state.session_state
|
|
assert captured["read_only"] is True
|
|
|
|
|
|
async def test_btw_full_mode_clones_full_registry(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""--tools=full keeps the whole registry with a fresh session, no context."""
|
|
|
|
from plyngent.agent import ToolRegistry, ToolTag, tool
|
|
from plyngent.tools.context import is_read_only_context
|
|
|
|
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
|
|
async def ro_tool() -> str:
|
|
return "ro"
|
|
|
|
@tool(tags=ToolTag.LOCAL, register=False)
|
|
async def w_tool() -> str:
|
|
return "w"
|
|
|
|
state.tools_enabled = True
|
|
state.agent.tools = ToolRegistry([ro_tool, w_tool], auto_bind_state=True)
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
|
|
captured["tools"] = kwargs.get("tools")
|
|
captured["session_state"] = kwargs.get("session_state")
|
|
captured["read_only"] = is_read_only_context()
|
|
if False:
|
|
yield None
|
|
|
|
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
|
|
|
|
assert await handle_slash(state, "/btw --tools full hi") is True
|
|
assert "btw done" in capsys.readouterr().out
|
|
|
|
assert captured["tools"] is True
|
|
assert captured["session_state"] is not state.session_state
|
|
assert captured["read_only"] is False
|
|
|
|
|
|
async def test_btw_sends_notice_and_shows_it(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
"""The aside request carries a notice; it never enters the main transcript."""
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
|
|
captured.update(kwargs)
|
|
if False:
|
|
yield None
|
|
|
|
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
|
|
before = list(state.agent.messages)
|
|
|
|
assert await handle_slash(state, "/btw --tools no hello") is True
|
|
|
|
notice = captured["notice"]
|
|
assert isinstance(notice, Notice)
|
|
assert notice.kind == "aside"
|
|
assert "Tools are disabled" in notice.body
|
|
# Side turns are not persisted, so nothing is pushed into the main history.
|
|
assert state.agent.messages == before
|
|
out = capsys.readouterr().out
|
|
assert "[notice] aside:" in out
|
|
assert "btw: hello" in out
|
|
|
|
|
|
async def test_btw_read_notice_states_read_only_scope(
|
|
state: ReplState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
from plyngent.agent import ToolRegistry, ToolTag, tool
|
|
|
|
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
|
|
async def ro_tool() -> str:
|
|
return "ro"
|
|
|
|
state.tools_enabled = True
|
|
state.agent.tools = ToolRegistry([ro_tool], auto_bind_state=True)
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
|
|
captured.update(kwargs)
|
|
if False:
|
|
yield None
|
|
|
|
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
|
|
|
|
assert await handle_slash(state, "/btw hi") is True
|
|
|
|
notice = captured["notice"]
|
|
assert isinstance(notice, Notice)
|
|
assert "read-only" in notice.body
|
|
assert "not saved" in notice.body
|
|
assert "[notice] aside: read tools" in capsys.readouterr().out
|
|
|
|
|
|
async def test_todos_update_takes_a_listing_number(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert await handle_slash(state, "/todos push first; second") is True
|
|
out = capsys.readouterr().out
|
|
assert "pushed group depth=1 items=[first, second]" in out
|
|
assert " [ ] 1. first" in out
|
|
assert " [ ] 2. second" in out
|
|
assert "t1" not in out
|
|
|
|
assert await handle_slash(state, "/todos done 2") is True
|
|
out = capsys.readouterr().out
|
|
assert "updated 2 → done" in out
|
|
assert " [x] 2. second" in out
|
|
|
|
# The stored id is not an address: only listing numbers are.
|
|
assert await handle_slash(state, "/todos done t1") is True
|
|
assert "takes an item number from the list" in capsys.readouterr().out
|
|
assert await handle_slash(state, "/todos done 7") is True
|
|
assert "no todo item 7" in capsys.readouterr().out
|
|
|
|
assert await handle_slash(state, "/todos pop") is True
|
|
assert "popped TOP group (first, second)" in capsys.readouterr().out
|
|
|
|
|
|
async def test_a_mention_attaches_the_picture_to_the_turn(state: ReplState, tmp_path: Path) -> None:
|
|
_accept_images(state)
|
|
shot = tmp_path / "shot.png"
|
|
_ = shot.write_bytes(_PNG)
|
|
state.agent.stream = False
|
|
|
|
payload = resolve_mentions(state, f"look at @{shot}")
|
|
assert payload.notes == []
|
|
assert len(payload.images) == 1
|
|
assert payload.text == f"look at @{shot}"
|
|
|
|
await _send_turn(state, payload.text)
|
|
user = next(m for m in state.agent.messages if isinstance(m, UserChatMessage))
|
|
assert isinstance(user.content, list)
|
|
text_part, image_part = user.content
|
|
assert isinstance(text_part, ChatTextPart)
|
|
assert text_part.text == f"look at @{shot}"
|
|
assert isinstance(image_part, ChatImagePart)
|
|
assert image_part.image_url.url.startswith("data:image/png;base64,")
|
|
|
|
|
|
async def test_a_mention_inlines_a_text_file(state: ReplState, tmp_path: Path) -> None:
|
|
notes = tmp_path / "notes.txt"
|
|
_ = notes.write_text("the deploy needs acme", encoding="utf-8")
|
|
|
|
payload = resolve_mentions(state, f"read @{notes}")
|
|
|
|
assert payload.images == []
|
|
assert payload.notes == []
|
|
assert payload.text.startswith(f"read @{notes}")
|
|
assert f"--- @{notes} (21 chars) ---" in payload.text
|
|
assert payload.text.endswith("the deploy needs acme")
|
|
|
|
|
|
async def test_a_mention_of_a_missing_file_is_a_note(state: ReplState, tmp_path: Path) -> None:
|
|
payload = resolve_mentions(state, "@nope.png is gone")
|
|
|
|
assert payload.images == []
|
|
assert payload.notes == ["@nope.png: file not found"]
|
|
assert payload.text == "@nope.png is gone"
|
|
|
|
|
|
async def test_a_picture_mention_is_skipped_for_a_text_only_model(
|
|
state: ReplState,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""``image_in`` is the gate: a text-only model never receives a picture."""
|
|
shot = tmp_path / "shot.png"
|
|
_ = shot.write_bytes(_PNG)
|
|
|
|
payload = resolve_mentions(state, f"@{shot}")
|
|
|
|
assert payload.images == []
|
|
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
|