cli/btw: tell the model side questions are unsaved and tool-scoped

This commit is contained in:
2026-09-19 09:24:38 +08:00
parent 8a8de1b018
commit 8b9233090f
4 changed files with 117 additions and 4 deletions
+43 -1
View File
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast, overload
import pytest
from plyngent.agent import ChatAgent, ToolRegistry, ToolTag, tool
from plyngent.agent import ChatAgent, ToolRegistry, ToolTag, aside_notice, tool
from plyngent.agent.todo_stack import TodoStack
from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import (
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
DeveloperChatMessage,
UserChatMessage,
)
from plyngent.memory import MemoryStore
@@ -120,6 +121,47 @@ async def test_run_aside_fresh_skips_history() -> None:
assert users == ["q"]
@pytest.mark.asyncio
async def test_run_aside_notice_reaches_model_but_not_history() -> None:
"""The aside notice is the model's last instruction; the host keeps none."""
client = ScriptedClient(["ok"])
agent = ChatAgent(cast("Any", client), model="m", stream=False)
agent.messages.append(UserChatMessage(content="prior main"))
before = list(agent.messages)
notice = aside_notice(tools_mode="read")
async for _ in agent.run_aside("side?", include_history=True, tools=False, notice=notice):
pass
payload = client.payloads[0]
assert isinstance(payload[-2], DeveloperChatMessage)
assert payload[-2].content == notice.to_message().content
assert "read-only" in payload[-2].content
assert isinstance(payload[-1], UserChatMessage)
assert payload[-1].content == "side?"
assert agent.messages == before
@pytest.mark.asyncio
async def test_run_aside_fresh_history_still_carries_notice() -> None:
client = ScriptedClient(["ok"])
agent = ChatAgent(cast("Any", client), model="m", stream=False)
async for _ in agent.run_aside(
"q",
include_history=False,
tools=False,
notice=aside_notice(tools_mode="no"),
):
pass
payload = client.payloads[0]
assert isinstance(payload[0], DeveloperChatMessage)
assert "Tools are disabled" in payload[0].content
assert isinstance(payload[-1], UserChatMessage)
assert payload[-1].content == "q"
@pytest.mark.asyncio
async def test_run_aside_tools_clones_registry_with_fresh_session() -> None:
hits: list[str] = []
+61 -1
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Literal, overload
import pytest
import tomlkit
from plyngent.agent import ChatAgent
from plyngent.agent import ChatAgent, Notice
from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
@@ -764,3 +764,63 @@ async def test_btw_full_mode_clones_full_registry(
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