fix(agent): scope subagent reply dedupe to origin message
Made-with: Cursor
This commit is contained in:
@@ -87,6 +87,42 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
||||
assert "Return exactly: OK" in user_content
|
||||
|
||||
|
||||
def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None:
|
||||
"""Sender ID should be included in runtime context when provided."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
messages = builder.build_messages(
|
||||
history=[],
|
||||
current_message="Return exactly: OK",
|
||||
channel="cli",
|
||||
chat_id="direct",
|
||||
sender_id="user-12345",
|
||||
)
|
||||
|
||||
user_content = messages[-1]["content"]
|
||||
assert isinstance(user_content, str)
|
||||
assert "Sender ID: user-12345" in user_content
|
||||
|
||||
|
||||
def test_runtime_context_excludes_sender_id_when_not_provided(tmp_path) -> None:
|
||||
"""Sender ID should not be present in runtime context when not provided."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
messages = builder.build_messages(
|
||||
history=[],
|
||||
current_message="Return exactly: OK",
|
||||
channel="cli",
|
||||
chat_id="direct",
|
||||
sender_id=None,
|
||||
)
|
||||
|
||||
user_content = messages[-1]["content"]
|
||||
assert isinstance(user_content, str)
|
||||
assert "Sender ID:" not in user_content
|
||||
|
||||
|
||||
def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
|
||||
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
|
||||
@@ -727,6 +727,7 @@ def test_set_tool_context_passes_thread_session_key_to_spawn(tmp_path: Path) ->
|
||||
loop._set_tool_context(
|
||||
"slack",
|
||||
"C123",
|
||||
message_id="msg-123",
|
||||
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
|
||||
session_key="slack:C123:1700.42",
|
||||
)
|
||||
@@ -734,6 +735,7 @@ def test_set_tool_context_passes_thread_session_key_to_spawn(tmp_path: Path) ->
|
||||
spawn_tool = loop.tools.get("spawn")
|
||||
assert spawn_tool is not None
|
||||
assert spawn_tool._session_key.get() == "slack:C123:1700.42"
|
||||
assert spawn_tool._origin_message_id.get() == "msg-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -766,14 +768,17 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
|
||||
chat_id="slack:C123",
|
||||
content="subagent result",
|
||||
session_key_override="slack:C123:1700.42",
|
||||
metadata={"subagent_task_id": "sub-1"},
|
||||
metadata={"subagent_task_id": "sub-1", "origin_message_id": "msg-123"},
|
||||
)
|
||||
)
|
||||
|
||||
assert outbound is not None
|
||||
assert outbound.channel == "slack"
|
||||
assert outbound.chat_id == "C123"
|
||||
assert outbound.metadata == {"slack": {"thread_ts": "1700.42"}}
|
||||
assert outbound.metadata == {
|
||||
"slack": {"thread_ts": "1700.42"},
|
||||
"origin_message_id": "msg-123",
|
||||
}
|
||||
assert "thread question" in seen["initial_messages"][1]["content"]
|
||||
|
||||
loop.sessions.invalidate("slack:C123:1700.42")
|
||||
|
||||
@@ -180,6 +180,7 @@ def test_get_history_preserves_reasoning_content():
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"reasoning_content": "hidden chain of thought",
|
||||
"thinking_blocks": [{"type": "thinking", "thinking": "hidden chain of thought", "signature": "sig"}],
|
||||
})
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
@@ -190,6 +191,11 @@ def test_get_history_preserves_reasoning_content():
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"reasoning_content": "hidden chain of thought",
|
||||
"thinking_blocks": [{
|
||||
"type": "thinking",
|
||||
"thinking": "hidden chain of thought",
|
||||
"signature": "sig",
|
||||
}],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.config.schema import ChannelsConfig
|
||||
from nanobot.providers.transcription import GroqTranscriptionProvider as _GroqProvider
|
||||
from nanobot.providers.transcription import OpenAITranscriptionProvider as _OpenAIProvider
|
||||
from nanobot.utils.restart import RestartNotice
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -338,9 +340,6 @@ async def test_base_channel_passes_language_to_groq_transcription_provider():
|
||||
# Transcription provider HTTP tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from nanobot.providers.transcription import GroqTranscriptionProvider as _GroqProvider
|
||||
from nanobot.providers.transcription import OpenAITranscriptionProvider as _OpenAIProvider
|
||||
|
||||
|
||||
class _StubResponse:
|
||||
def raise_for_status(self):
|
||||
@@ -791,6 +790,50 @@ async def test_send_with_retry_skips_send_when_streamed():
|
||||
assert send_delta_called is False
|
||||
|
||||
|
||||
def test_outbound_duplicate_suppression_is_scoped_to_origin_message() -> None:
|
||||
fake_config = SimpleNamespace(
|
||||
channels=ChannelsConfig(send_max_retries=3),
|
||||
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||
)
|
||||
|
||||
mgr = ChannelManager.__new__(ChannelManager)
|
||||
mgr.config = fake_config
|
||||
mgr.bus = MessageBus()
|
||||
mgr.channels = {}
|
||||
mgr._dispatch_task = None
|
||||
mgr._origin_reply_fingerprints = {}
|
||||
|
||||
first = OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="chat123",
|
||||
content="Done",
|
||||
metadata={"message_id": "msg-1"},
|
||||
)
|
||||
duplicate = OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="chat123",
|
||||
content=" Done ",
|
||||
metadata={"origin_message_id": "msg-1"},
|
||||
)
|
||||
separate_turn = OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="chat123",
|
||||
content="Done",
|
||||
metadata={"message_id": "msg-2"},
|
||||
)
|
||||
new_origin_content = OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="chat123",
|
||||
content="Done with extra details",
|
||||
metadata={"origin_message_id": "msg-1"},
|
||||
)
|
||||
|
||||
assert mgr._should_suppress_outbound(first) is False
|
||||
assert mgr._should_suppress_outbound(duplicate) is True
|
||||
assert mgr._should_suppress_outbound(separate_turn) is False
|
||||
assert mgr._should_suppress_outbound(new_origin_content) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_with_retry_propagates_cancelled_error():
|
||||
"""_send_with_retry should re-raise CancelledError for graceful shutdown."""
|
||||
|
||||
@@ -2,7 +2,6 @@ import asyncio
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -17,19 +16,27 @@ except ImportError:
|
||||
if not DINGTALK_AVAILABLE:
|
||||
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
import nanobot.channels.dingtalk as dingtalk_module
|
||||
from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler
|
||||
from nanobot.channels.dingtalk import DingTalkConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.dingtalk import DingTalkChannel, DingTalkConfig, NanobotDingTalkHandler
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int = 200, json_body: dict | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int = 200,
|
||||
json_body: dict | None = None,
|
||||
*,
|
||||
content: bytes = b"",
|
||||
headers: dict[str, str] | None = None,
|
||||
url: str = "https://example.com/file",
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self._json_body = json_body or {}
|
||||
self.text = "{}"
|
||||
self.content = b""
|
||||
self.headers = {"content-type": "application/json"}
|
||||
self.text = content.decode("utf-8", errors="replace") if content else "{}"
|
||||
self.content = content
|
||||
self.headers = headers or {"content-type": "application/json"}
|
||||
self.url = httpx.URL(url)
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._json_body
|
||||
@@ -46,11 +53,13 @@ class _FakeHttp:
|
||||
return _FakeResponse()
|
||||
|
||||
async def post(self, url: str, json=None, headers=None, **kwargs):
|
||||
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
|
||||
self.calls.append(
|
||||
{"method": "POST", "url": url, "json": json, "headers": headers, "kwargs": kwargs}
|
||||
)
|
||||
return self._next_response()
|
||||
|
||||
async def get(self, url: str, **kwargs):
|
||||
self.calls.append({"method": "GET", "url": url})
|
||||
self.calls.append({"method": "GET", "url": url, "kwargs": kwargs})
|
||||
return self._next_response()
|
||||
|
||||
|
||||
@@ -242,6 +251,245 @@ async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
||||
assert channel._http.calls[1]["method"] == "GET"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_media_bytes_rejects_private_http_target_before_fetch() -> None:
|
||||
"""Remote media fetches must not reach loopback/private addresses."""
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._http = _FakeHttp(
|
||||
responses=[
|
||||
_FakeResponse(
|
||||
200,
|
||||
content=b"internal secret",
|
||||
headers={"content-type": "text/plain"},
|
||||
url="http://127.0.0.1/admin.txt",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
data, filename, content_type = await channel._read_media_bytes("http://127.0.0.1/admin.txt")
|
||||
|
||||
assert (data, filename, content_type) == (None, None, None)
|
||||
assert channel._http.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_media_bytes_rejects_private_redirect_result() -> None:
|
||||
"""A public-looking media URL must not be accepted after redirecting private."""
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._http = _FakeHttp(
|
||||
responses=[
|
||||
_FakeResponse(
|
||||
200,
|
||||
content=b"metadata bytes",
|
||||
headers={"content-type": "text/plain"},
|
||||
url="http://127.0.0.1/metadata",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
data, filename, content_type = await channel._read_media_bytes("https://example.com/safe.txt")
|
||||
|
||||
assert (data, filename, content_type) == (None, None, None)
|
||||
assert len(channel._http.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_media_bytes_rejects_oversized_remote_response(monkeypatch) -> None:
|
||||
"""DingTalk media downloads should enforce a byte cap before upload."""
|
||||
monkeypatch.setattr(dingtalk_module, "DINGTALK_MAX_REMOTE_MEDIA_BYTES", 8, raising=False)
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._http = _FakeHttp(
|
||||
responses=[
|
||||
_FakeResponse(
|
||||
200,
|
||||
content=b"123456789",
|
||||
headers={"content-type": "text/plain"},
|
||||
url="https://example.com/large.txt",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
data, filename, content_type = await channel._read_media_bytes("https://example.com/large.txt")
|
||||
|
||||
assert (data, filename, content_type) == (None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_media_bytes_does_not_follow_remote_redirects_by_default() -> None:
|
||||
"""Redirects are refused by default instead of followed into internal networks."""
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._http = _FakeHttp(
|
||||
responses=[
|
||||
_FakeResponse(
|
||||
302,
|
||||
headers={"location": "http://127.0.0.1/metadata"},
|
||||
url="https://example.com/redirect.txt",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||
|
||||
assert (data, filename, content_type) == (None, None, None)
|
||||
assert channel._http.calls[0]["kwargs"]["follow_redirects"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_media_bytes_follows_safe_redirect_when_explicitly_enabled() -> None:
|
||||
"""Operators can opt in to public redirects without enabling private redirects."""
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(
|
||||
client_id="app",
|
||||
client_secret="secret",
|
||||
allow_from=["*"],
|
||||
allow_remote_media_redirects=True,
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._http = _FakeHttp(
|
||||
responses=[
|
||||
_FakeResponse(
|
||||
302,
|
||||
headers={"location": "https://example.com/final.txt"},
|
||||
url="https://example.com/redirect.txt",
|
||||
),
|
||||
_FakeResponse(
|
||||
200,
|
||||
content=b"redirected media",
|
||||
headers={"content-type": "text/plain"},
|
||||
url="https://example.com/final.txt",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||
|
||||
assert (data, filename, content_type) == (b"redirected media", "redirect.txt", "text/plain")
|
||||
assert [call["url"] for call in channel._http.calls] == [
|
||||
"https://example.com/redirect.txt",
|
||||
"https://example.com/final.txt",
|
||||
]
|
||||
assert all(call["kwargs"]["follow_redirects"] is False for call in channel._http.calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_media_bytes_blocks_cross_host_redirect_without_allowlist() -> None:
|
||||
"""Redirect opt-in should not allow arbitrary cross-host redirects by default."""
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(
|
||||
client_id="app",
|
||||
client_secret="secret",
|
||||
allow_from=["*"],
|
||||
allow_remote_media_redirects=True,
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._http = _FakeHttp(
|
||||
responses=[
|
||||
_FakeResponse(
|
||||
302,
|
||||
headers={"location": "https://example.org/final.txt"},
|
||||
url="https://example.com/redirect.txt",
|
||||
),
|
||||
_FakeResponse(
|
||||
200,
|
||||
content=b"cross-host media",
|
||||
headers={"content-type": "text/plain"},
|
||||
url="https://example.org/final.txt",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||
|
||||
assert (data, filename, content_type) == (None, None, None)
|
||||
assert [call["url"] for call in channel._http.calls] == ["https://example.com/redirect.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_media_bytes_allows_cross_host_redirect_when_allowlisted() -> None:
|
||||
"""Operators can explicitly allow a known CDN/download host for redirects."""
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(
|
||||
client_id="app",
|
||||
client_secret="secret",
|
||||
allow_from=["*"],
|
||||
allow_remote_media_redirects=True,
|
||||
remote_media_redirect_allowed_hosts=["example.org"],
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._http = _FakeHttp(
|
||||
responses=[
|
||||
_FakeResponse(
|
||||
302,
|
||||
headers={"location": "https://example.org/final.txt"},
|
||||
url="https://example.com/redirect.txt",
|
||||
),
|
||||
_FakeResponse(
|
||||
200,
|
||||
content=b"cross-host media",
|
||||
headers={"content-type": "text/plain"},
|
||||
url="https://example.org/final.txt",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||
|
||||
assert (data, filename, content_type) == (b"cross-host media", "redirect.txt", "text/plain")
|
||||
assert [call["url"] for call in channel._http.calls] == [
|
||||
"https://example.com/redirect.txt",
|
||||
"https://example.org/final.txt",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_media_bytes_blocks_private_redirect_even_when_redirects_enabled() -> None:
|
||||
"""Redirect opt-in must still validate each hop before fetching it."""
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(
|
||||
client_id="app",
|
||||
client_secret="secret",
|
||||
allow_from=["*"],
|
||||
allow_remote_media_redirects=True,
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._http = _FakeHttp(
|
||||
responses=[
|
||||
_FakeResponse(
|
||||
302,
|
||||
headers={"location": "http://127.0.0.1/metadata"},
|
||||
url="https://example.com/redirect.txt",
|
||||
),
|
||||
_FakeResponse(
|
||||
200,
|
||||
content=b"internal secret",
|
||||
headers={"content-type": "text/plain"},
|
||||
url="http://127.0.0.1/metadata",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
|
||||
|
||||
assert (data, filename, content_type) == (None, None, None)
|
||||
assert [call["url"] for call in channel._http.calls] == ["https://example.com/redirect.txt"]
|
||||
|
||||
|
||||
def test_normalize_upload_payload_zips_html_attachment() -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
|
||||
@@ -380,6 +380,62 @@ async def test_on_message_skips_typing_for_self_message() -> None:
|
||||
assert client.typing_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_skips_pre_startup_event() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
channel._started_at_ms = 1_000_000
|
||||
|
||||
handled: list[str] = []
|
||||
|
||||
async def _fake_handle_message(**kwargs) -> None:
|
||||
handled.append(kwargs["sender_id"])
|
||||
|
||||
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
|
||||
|
||||
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
|
||||
old_event = SimpleNamespace(
|
||||
sender="@alice:matrix.org", body="old", source={}, server_timestamp=999_999
|
||||
)
|
||||
fresh_event = SimpleNamespace(
|
||||
sender="@alice:matrix.org", body="fresh", source={}, server_timestamp=1_000_001
|
||||
)
|
||||
|
||||
await channel._on_message(room, old_event)
|
||||
await channel._on_message(room, fresh_event)
|
||||
|
||||
assert handled == ["@alice:matrix.org"]
|
||||
assert client.typing_calls == [
|
||||
("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_media_message_skips_pre_startup_event() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
channel._started_at_ms = 1_000_000
|
||||
|
||||
handled: list[str] = []
|
||||
|
||||
async def _fake_handle_message(**kwargs) -> None:
|
||||
handled.append(kwargs["sender_id"])
|
||||
|
||||
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
|
||||
|
||||
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
|
||||
old_event = SimpleNamespace(
|
||||
sender="@alice:matrix.org", body="old", source={}, server_timestamp=999_999
|
||||
)
|
||||
|
||||
await channel._on_media_message(room, old_event)
|
||||
|
||||
assert handled == []
|
||||
assert client.typing_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_skips_typing_for_denied_sender() -> None:
|
||||
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
|
||||
@@ -1190,6 +1246,44 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None:
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_empty_content_does_not_call_room_send() -> None:
|
||||
"""Progress messages with empty content must not produce an empty body: '' event."""
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="matrix",
|
||||
chat_id="!room:matrix.org",
|
||||
content="",
|
||||
metadata={"_progress": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert client.room_send_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_whitespace_only_content_does_not_call_room_send() -> None:
|
||||
"""Progress messages with whitespace-only content must not produce an empty message."""
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="matrix",
|
||||
chat_id="!room:matrix.org",
|
||||
content=" \n\n ",
|
||||
metadata={"_progress": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert client.room_send_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_clears_typing_when_send_fails() -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Tests for the native AWS Bedrock Converse provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import Config, ProvidersConfig
|
||||
from nanobot.providers.bedrock_provider import BedrockProvider
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
response: dict[str, Any] | None = None,
|
||||
stream_events: list[dict[str, Any]] | None = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.response = response
|
||||
self.stream_events = stream_events or []
|
||||
self.error = error
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.stream_calls: list[dict[str, Any]] = []
|
||||
|
||||
def converse(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.response or {}
|
||||
|
||||
def converse_stream(self, **kwargs):
|
||||
self.stream_calls.append(kwargs)
|
||||
if self.error:
|
||||
raise self.error
|
||||
return {"stream": iter(self.stream_events)}
|
||||
|
||||
|
||||
class FakeBedrockError(Exception):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("too many requests")
|
||||
self.response = {
|
||||
"ResponseMetadata": {
|
||||
"HTTPStatusCode": 429,
|
||||
"HTTPHeaders": {"retry-after": "3"},
|
||||
},
|
||||
"Error": {
|
||||
"Code": "ThrottlingException",
|
||||
"Message": "Rate exceeded",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_bedrock_provider_is_registered_and_matches_without_api_key() -> None:
|
||||
spec = find_by_name("bedrock")
|
||||
assert spec is not None
|
||||
assert spec.backend == "bedrock"
|
||||
assert spec.is_direct is True
|
||||
assert hasattr(ProvidersConfig(), "bedrock")
|
||||
|
||||
cfg = Config.model_validate({
|
||||
"agents": {"defaults": {"model": "bedrock/global.anthropic.claude-opus-4-7"}},
|
||||
"providers": {"bedrock": {"region": "us-east-1"}},
|
||||
})
|
||||
|
||||
assert cfg.get_provider_name() == "bedrock"
|
||||
assert cfg.get_provider().region == "us-east-1"
|
||||
|
||||
|
||||
def test_opus_47_uses_adaptive_thinking_and_omits_temperature() -> None:
|
||||
provider = BedrockProvider(region="us-east-1", client=FakeClient())
|
||||
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
model="bedrock/global.anthropic.claude-opus-4-7",
|
||||
max_tokens=2048,
|
||||
temperature=0.1,
|
||||
reasoning_effort="medium",
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert kwargs["modelId"] == "global.anthropic.claude-opus-4-7"
|
||||
assert kwargs["inferenceConfig"] == {"maxTokens": 2048}
|
||||
assert kwargs["additionalModelRequestFields"]["thinking"] == {
|
||||
"type": "adaptive",
|
||||
"effort": "medium",
|
||||
}
|
||||
|
||||
|
||||
def test_generic_bedrock_model_keeps_temperature_and_skips_anthropic_thinking() -> None:
|
||||
provider = BedrockProvider(region="us-east-1", client=FakeClient())
|
||||
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
model="bedrock/amazon.nova-lite-v1:0",
|
||||
max_tokens=1024,
|
||||
temperature=0.3,
|
||||
reasoning_effort="medium",
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert kwargs["modelId"] == "amazon.nova-lite-v1:0"
|
||||
assert kwargs["inferenceConfig"] == {"maxTokens": 1024, "temperature": 0.3}
|
||||
assert "additionalModelRequestFields" not in kwargs
|
||||
|
||||
|
||||
def test_build_kwargs_converts_messages_tools_and_tool_results() -> None:
|
||||
provider = BedrockProvider(region="us-east-1", client=FakeClient())
|
||||
tools = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
|
||||
},
|
||||
}]
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "read x"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "toolu_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path": "x"}'},
|
||||
}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "toolu_1", "name": "read_file", "content": "ok"},
|
||||
{"role": "user", "content": "continue"},
|
||||
]
|
||||
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
model="bedrock/anthropic.claude-opus-4-7",
|
||||
max_tokens=1024,
|
||||
temperature=0.7,
|
||||
reasoning_effort=None,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
assert kwargs["system"] == [{"text": "You are helpful."}]
|
||||
assert kwargs["messages"][1]["content"] == [{
|
||||
"toolUse": {
|
||||
"toolUseId": "toolu_1",
|
||||
"name": "read_file",
|
||||
"input": {"path": "x"},
|
||||
}
|
||||
}]
|
||||
assert kwargs["messages"][2]["role"] == "user"
|
||||
assert kwargs["messages"][2]["content"][0]["toolResult"]["toolUseId"] == "toolu_1"
|
||||
assert kwargs["messages"][2]["content"][1] == {"text": "continue"}
|
||||
tool_spec = kwargs["toolConfig"]["tools"][0]["toolSpec"]
|
||||
assert tool_spec["name"] == "read_file"
|
||||
assert kwargs["toolConfig"]["toolChoice"] == {"any": {}}
|
||||
|
||||
|
||||
def test_parse_response_maps_text_tools_reasoning_usage_and_stop_reason() -> None:
|
||||
response = {
|
||||
"output": {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"reasoningContent": {"reasoningText": {"text": "think", "signature": "sig"}}},
|
||||
{"text": "hello"},
|
||||
{"toolUse": {"toolUseId": "t1", "name": "search", "input": {"q": "x"}}},
|
||||
],
|
||||
}
|
||||
},
|
||||
"stopReason": "tool_use",
|
||||
"usage": {
|
||||
"inputTokens": 10,
|
||||
"outputTokens": 5,
|
||||
"totalTokens": 15,
|
||||
"cacheReadInputTokens": 2,
|
||||
},
|
||||
}
|
||||
|
||||
result = BedrockProvider._parse_response(response)
|
||||
|
||||
assert result.content == "hello"
|
||||
assert result.finish_reason == "tool_calls"
|
||||
assert result.usage["prompt_tokens"] == 10
|
||||
assert result.usage["cached_tokens"] == 2
|
||||
assert result.reasoning_content == "think"
|
||||
assert result.thinking_blocks == [{"type": "thinking", "thinking": "think", "signature": "sig"}]
|
||||
assert result.tool_calls[0].id == "t1"
|
||||
assert result.tool_calls[0].arguments == {"q": "x"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_aggregates_text_tool_use_and_usage() -> None:
|
||||
client = FakeClient(stream_events=[
|
||||
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "he"}}},
|
||||
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "llo"}}},
|
||||
{
|
||||
"contentBlockStart": {
|
||||
"contentBlockIndex": 1,
|
||||
"start": {"toolUse": {"toolUseId": "t1", "name": "search"}},
|
||||
}
|
||||
},
|
||||
{
|
||||
"contentBlockDelta": {
|
||||
"contentBlockIndex": 1,
|
||||
"delta": {"toolUse": {"input": '{"q":'}},
|
||||
}
|
||||
},
|
||||
{
|
||||
"contentBlockDelta": {
|
||||
"contentBlockIndex": 1,
|
||||
"delta": {"toolUse": {"input": '"x"}'}},
|
||||
}
|
||||
},
|
||||
{"contentBlockStop": {"contentBlockIndex": 1}},
|
||||
{"messageStop": {"stopReason": "tool_use"}},
|
||||
{"metadata": {"usage": {"inputTokens": 3, "outputTokens": 4, "totalTokens": 7}}},
|
||||
])
|
||||
provider = BedrockProvider(region="us-east-1", client=client)
|
||||
deltas: list[str] = []
|
||||
|
||||
result = await provider.chat_stream(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="bedrock/anthropic.claude-opus-4-7",
|
||||
on_content_delta=lambda text: _append_delta(deltas, text),
|
||||
)
|
||||
|
||||
assert deltas == ["he", "llo"]
|
||||
assert result.content == "hello"
|
||||
assert result.finish_reason == "tool_calls"
|
||||
assert result.usage == {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}
|
||||
assert result.tool_calls[0].name == "search"
|
||||
assert result.tool_calls[0].arguments == {"q": "x"}
|
||||
|
||||
|
||||
async def _append_delta(deltas: list[str], text: str) -> None:
|
||||
deltas.append(text)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_error_maps_retry_metadata() -> None:
|
||||
provider = BedrockProvider(region="us-east-1", client=FakeClient(error=FakeBedrockError()))
|
||||
|
||||
result = await provider.chat(messages=[{"role": "user", "content": "hi"}])
|
||||
|
||||
assert result.finish_reason == "error"
|
||||
assert result.error_status_code == 429
|
||||
assert result.error_should_retry is True
|
||||
assert result.error_code == "throttlingexception"
|
||||
assert result.retry_after == 3
|
||||
@@ -13,6 +13,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.openai_codex_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.github_copilot_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.azure_openai_provider", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "nanobot.providers.bedrock_provider", raising=False)
|
||||
|
||||
providers = importlib.import_module("nanobot.providers")
|
||||
|
||||
@@ -21,6 +22,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
assert "nanobot.providers.openai_codex_provider" not in sys.modules
|
||||
assert "nanobot.providers.github_copilot_provider" not in sys.modules
|
||||
assert "nanobot.providers.azure_openai_provider" not in sys.modules
|
||||
assert "nanobot.providers.bedrock_provider" not in sys.modules
|
||||
assert providers.__all__ == [
|
||||
"LLMProvider",
|
||||
"LLMResponse",
|
||||
@@ -29,6 +31,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
|
||||
"OpenAICodexProvider",
|
||||
"GitHubCopilotProvider",
|
||||
"AzureOpenAIProvider",
|
||||
"BedrockProvider",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from nanobot.api.server import (
|
||||
_sse_chunk,
|
||||
_SSE_DONE,
|
||||
_sse_chunk,
|
||||
create_app,
|
||||
)
|
||||
|
||||
@@ -111,13 +111,13 @@ async def test_stream_true_returns_sse(aiohttp_client) -> None:
|
||||
assert resp.content_type == "text/event-stream"
|
||||
|
||||
body = await resp.text()
|
||||
lines = [l for l in body.split("\n") if l.startswith("data: ")]
|
||||
lines = [line for line in body.split("\n") if line.startswith("data: ")]
|
||||
|
||||
# Should have: 2 token chunks + 1 finish chunk + [DONE]
|
||||
data_lines = [l[len("data: "):] for l in lines]
|
||||
data_lines = [line[len("data: "):] for line in lines]
|
||||
assert data_lines[-1] == "[DONE]"
|
||||
|
||||
chunks = [json.loads(l) for l in data_lines[:-1]]
|
||||
chunks = [json.loads(line) for line in data_lines[:-1]]
|
||||
assert chunks[0]["choices"][0]["delta"]["content"] == "Hello"
|
||||
assert chunks[1]["choices"][0]["delta"]["content"] == " world"
|
||||
# Last chunk before [DONE] should have finish_reason=stop
|
||||
@@ -181,8 +181,12 @@ async def test_stream_sse_chunk_ids_are_consistent(aiohttp_client) -> None:
|
||||
json={"messages": [{"role": "user", "content": "go"}], "stream": True},
|
||||
)
|
||||
body = await resp.text()
|
||||
data_lines = [l[len("data: "):] for l in body.split("\n") if l.startswith("data: ") and l != "data: [DONE]"]
|
||||
chunks = [json.loads(l) for l in data_lines]
|
||||
data_lines = [
|
||||
line[len("data: "):]
|
||||
for line in body.split("\n")
|
||||
if line.startswith("data: ") and line != "data: [DONE]"
|
||||
]
|
||||
chunks = [json.loads(line) for line in data_lines]
|
||||
|
||||
chunk_ids = {c["id"] for c in chunks}
|
||||
assert len(chunk_ids) == 1, f"Expected single chunk id, got {chunk_ids}"
|
||||
@@ -218,6 +222,85 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None:
|
||||
assert captured_kwargs.get("on_stream_end") is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None:
|
||||
"""Intermediate stream-end callbacks should not terminate the HTTP stream."""
|
||||
agent = MagicMock()
|
||||
|
||||
async def fake_process_direct(*, on_stream=None, on_stream_end=None, **kwargs):
|
||||
assert on_stream is not None
|
||||
assert on_stream_end is not None
|
||||
await on_stream("planning")
|
||||
await on_stream_end(resuming=True)
|
||||
await asyncio.sleep(0.05)
|
||||
await on_stream(" final")
|
||||
await on_stream_end(resuming=False)
|
||||
return "planning final"
|
||||
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "use a tool"}], "stream": True},
|
||||
)
|
||||
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
data_lines = [
|
||||
line[len("data: "):] for line in body.split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert data_lines[-1] == "[DONE]"
|
||||
|
||||
chunks = [json.loads(line) for line in data_lines[:-1]]
|
||||
deltas = [c["choices"][0]["delta"].get("content", "") for c in chunks]
|
||||
assert "planning" in deltas
|
||||
assert " final" in deltas
|
||||
assert chunks[-1]["choices"][0]["finish_reason"] == "stop"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None:
|
||||
"""stream=true should not return an empty stream when the agent returns content."""
|
||||
agent = MagicMock()
|
||||
|
||||
async def fake_process_direct(*, on_stream=None, on_stream_end=None, **kwargs):
|
||||
assert on_stream is not None
|
||||
assert on_stream_end is not None
|
||||
await on_stream_end(resuming=False)
|
||||
return "plain final"
|
||||
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "hi"}], "stream": True},
|
||||
)
|
||||
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
data_lines = [
|
||||
line[len("data: "):] for line in body.split("\n") if line.startswith("data: ")
|
||||
]
|
||||
chunks = [json.loads(line) for line in data_lines[:-1]]
|
||||
deltas = [c["choices"][0]["delta"].get("content", "") for c in chunks]
|
||||
|
||||
assert "plain final" in deltas
|
||||
assert data_lines[-1] == "[DONE]"
|
||||
assert chunks[-1]["choices"][0]["finish_reason"] == "stop"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_with_session_id(aiohttp_client) -> None:
|
||||
|
||||
@@ -27,12 +27,16 @@ class TestEditReadTracking:
|
||||
"""edit_file should warn when file hasn't been read first."""
|
||||
|
||||
@pytest.fixture()
|
||||
def read_tool(self, tmp_path):
|
||||
return ReadFileTool(workspace=tmp_path)
|
||||
def file_states(self):
|
||||
return file_state.FileStates()
|
||||
|
||||
@pytest.fixture()
|
||||
def edit_tool(self, tmp_path):
|
||||
return EditFileTool(workspace=tmp_path)
|
||||
def read_tool(self, tmp_path, file_states):
|
||||
return ReadFileTool(workspace=tmp_path, file_states=file_states)
|
||||
|
||||
@pytest.fixture()
|
||||
def edit_tool(self, tmp_path, file_states):
|
||||
return EditFileTool(workspace=tmp_path, file_states=file_states)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_warns_if_file_not_read_first(self, edit_tool, tmp_path):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -166,35 +166,3 @@ class TestMessageToolTurnTracking:
|
||||
tool._sent_in_turn = True
|
||||
tool.start_turn()
|
||||
assert not tool._sent_in_turn
|
||||
|
||||
|
||||
class TestSystemReplySuppression:
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_system_reply_suppressed_when_duplicate(self, tmp_path: Path) -> None:
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager") as MockSessionManager, \
|
||||
patch("nanobot.agent.loop.SubagentManager"):
|
||||
session = MagicMock()
|
||||
session.get_history.return_value = []
|
||||
MockSessionManager.return_value.get_or_create.return_value = session
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10)
|
||||
|
||||
loop._remember_visible_reply("feishu:chat123", "Done")
|
||||
loop._run_agent_loop = AsyncMock(return_value=("Done", [], []))
|
||||
loop._save_turn = MagicMock()
|
||||
loop.sessions.save = MagicMock()
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id="feishu:chat123",
|
||||
content="background result",
|
||||
metadata={"source": "subagent"},
|
||||
)
|
||||
|
||||
result = await loop._process_message(msg)
|
||||
assert result is None
|
||||
|
||||
@@ -97,6 +97,67 @@ class TestReadDedup:
|
||||
assert isinstance(second, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-session isolation (issue #3571)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Each session must keep its own read cache. When session A reads a file,
|
||||
# session B reading the same file must still receive the full content, not
|
||||
# the "[File unchanged since last read]" dedup stub. The stub is only valid
|
||||
# within the session that first cached the read.
|
||||
|
||||
class TestReadDedupSessionIsolation:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_separate_sessions_do_not_share_dedup_state(self, tmp_path):
|
||||
f = tmp_path / "shared.txt"
|
||||
f.write_text("\n".join(f"line {i}" for i in range(10)), encoding="utf-8")
|
||||
|
||||
session_a_tool = ReadFileTool(workspace=tmp_path)
|
||||
session_b_tool = ReadFileTool(workspace=tmp_path)
|
||||
|
||||
first = await session_a_tool.execute(path=str(f))
|
||||
assert "line 0" in first
|
||||
|
||||
# Session B has never read this file before — it must see the full
|
||||
# content, not the dedup stub from session A.
|
||||
second = await session_b_tool.execute(path=str(f))
|
||||
assert "unchanged" not in second.lower(), (
|
||||
"Session B should not inherit session A's read-dedup state. "
|
||||
f"Got: {second!r}"
|
||||
)
|
||||
assert "line 0" in second
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_loop_tool_uses_bound_session_state(self, tmp_path):
|
||||
f = tmp_path / "shared.txt"
|
||||
f.write_text("\n".join(f"line {i}" for i in range(10)), encoding="utf-8")
|
||||
|
||||
# AgentLoop registers one shared ReadFileTool instance. The session
|
||||
# boundary is the task-local FileStates binding, not the tool object.
|
||||
shared_tool = ReadFileTool(workspace=tmp_path)
|
||||
session_a = file_state.FileStates()
|
||||
session_b = file_state.FileStates()
|
||||
|
||||
token = file_state.bind_file_states(session_a)
|
||||
try:
|
||||
first = await shared_tool.execute(path=str(f))
|
||||
repeat = await shared_tool.execute(path=str(f))
|
||||
finally:
|
||||
file_state.reset_file_states(token)
|
||||
|
||||
assert "line 0" in first
|
||||
assert "unchanged" in repeat.lower()
|
||||
|
||||
token = file_state.bind_file_states(session_b)
|
||||
try:
|
||||
second_session_read = await shared_tool.execute(path=str(f))
|
||||
finally:
|
||||
file_state.reset_file_states(token)
|
||||
|
||||
assert "unchanged" not in second_session_read.lower()
|
||||
assert "line 0" in second_session_read
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user