mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08c5ce95f2 | ||
|
|
d5122f6df8 | ||
|
|
1b231eb69f | ||
|
|
91d5f14fbd | ||
|
|
3ece7256d1 | ||
|
|
a0e97e360e | ||
|
|
934372d90b | ||
|
|
9eff9a70bb | ||
|
|
5c2c1bb9ef | ||
|
|
d40ce81a3d | ||
|
|
8a646d9aec | ||
|
|
93bcb0a649 | ||
|
|
da0ebc64fb | ||
|
|
9bf7f3b420 | ||
|
|
a4a197fea5 | ||
|
|
bc3d734df5 | ||
|
|
1835f94d8e | ||
|
|
c51b653154 | ||
|
|
51cb260f05 | ||
|
|
e4fa58ef45 | ||
|
|
f2848b9b94 | ||
|
|
e49b56525b | ||
|
|
3454efcd98 | ||
|
|
c7057cb3bf | ||
|
|
8301a3a741 | ||
|
|
1826bfd05a | ||
|
|
197ecb02ca | ||
|
|
74d314d3ef | ||
|
|
375b1f0328 | ||
|
|
a7caee1186 |
@@ -9,7 +9,7 @@ from typing import Any
|
|||||||
|
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime
|
from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime, truncate_text
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ class ContextBuilder:
|
|||||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
|
||||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
|
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
||||||
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
||||||
|
|
||||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||||
@@ -56,9 +57,11 @@ class ContextBuilder:
|
|||||||
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
|
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
|
||||||
if entries:
|
if entries:
|
||||||
capped = entries[-self._MAX_RECENT_HISTORY:]
|
capped = entries[-self._MAX_RECENT_HISTORY:]
|
||||||
parts.append("# Recent History\n\n" + "\n".join(
|
history_text = "\n".join(
|
||||||
f"- [{e['timestamp']}] {e['content']}" for e in capped
|
f"- [{e['timestamp']}] {e['content']}" for e in capped
|
||||||
))
|
)
|
||||||
|
history_text = truncate_text(history_text, self._MAX_HISTORY_CHARS)
|
||||||
|
parts.append("# Recent History\n\n" + history_text)
|
||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|||||||
+41
-8
@@ -423,15 +423,18 @@ class AgentLoop:
|
|||||||
self._set_runtime_checkpoint(session, payload)
|
self._set_runtime_checkpoint(session, payload)
|
||||||
|
|
||||||
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
|
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
|
||||||
"""Non-blocking drain of follow-up messages from the pending queue."""
|
"""Drain follow-up messages from the pending queue.
|
||||||
|
|
||||||
|
When no messages are immediately available but sub-agents
|
||||||
|
spawned in this dispatch are still running, blocks until at
|
||||||
|
least one result arrives (or timeout). This keeps the runner
|
||||||
|
loop alive so subsequent sub-agent completions are consumed
|
||||||
|
in-order rather than dispatched separately.
|
||||||
|
"""
|
||||||
if pending_queue is None:
|
if pending_queue is None:
|
||||||
return []
|
return []
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
while len(items) < limit:
|
def _to_user_message(pending_msg: InboundMessage) -> dict[str, Any]:
|
||||||
try:
|
|
||||||
pending_msg = pending_queue.get_nowait()
|
|
||||||
except asyncio.QueueEmpty:
|
|
||||||
break
|
|
||||||
content = pending_msg.content
|
content = pending_msg.content
|
||||||
media = pending_msg.media if pending_msg.media else None
|
media = pending_msg.media if pending_msg.media else None
|
||||||
if media:
|
if media:
|
||||||
@@ -447,7 +450,36 @@ class AgentLoop:
|
|||||||
merged: str | list[dict[str, Any]] = f"{runtime_ctx}\n\n{user_content}"
|
merged: str | list[dict[str, Any]] = f"{runtime_ctx}\n\n{user_content}"
|
||||||
else:
|
else:
|
||||||
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
||||||
items.append({"role": "user", "content": merged})
|
return {"role": "user", "content": merged}
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
while len(items) < limit:
|
||||||
|
try:
|
||||||
|
items.append(_to_user_message(pending_queue.get_nowait()))
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Block if nothing drained but sub-agents spawned in this dispatch
|
||||||
|
# are still running. Keeps the runner loop alive so subsequent
|
||||||
|
# completions are injected in-order rather than dispatched separately.
|
||||||
|
if (not items
|
||||||
|
and session is not None
|
||||||
|
and self.subagents.get_running_count_by_session(session.key) > 0):
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(pending_queue.get(), timeout=300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Timeout waiting for sub-agent completion in session {}",
|
||||||
|
session.key,
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
items.append(_to_user_message(msg))
|
||||||
|
while len(items) < limit:
|
||||||
|
try:
|
||||||
|
items.append(_to_user_message(pending_queue.get_nowait()))
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
result = await self.runner.run(AgentRunSpec(
|
result = await self.runner.run(AgentRunSpec(
|
||||||
@@ -744,6 +776,7 @@ class AgentLoop:
|
|||||||
final_content, _, all_msgs, _, _ = await self._run_agent_loop(
|
final_content, _, all_msgs, _, _ = await self._run_agent_loop(
|
||||||
messages, session=session, channel=channel, chat_id=chat_id,
|
messages, session=session, channel=channel, chat_id=chat_id,
|
||||||
message_id=msg.metadata.get("message_id"),
|
message_id=msg.metadata.get("message_id"),
|
||||||
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
self._save_turn(session, all_msgs, 1 + len(history))
|
self._save_turn(session, all_msgs, 1 + len(history))
|
||||||
self._clear_runtime_checkpoint(session)
|
self._clear_runtime_checkpoint(session)
|
||||||
|
|||||||
+30
-29
@@ -6,6 +6,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import weakref
|
import weakref
|
||||||
|
import tiktoken
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
||||||
@@ -13,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think
|
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think, truncate_text
|
||||||
|
|
||||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
@@ -373,11 +374,13 @@ class MemoryStore:
|
|||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def raw_archive(self, messages: list[dict]) -> None:
|
def raw_archive(self, messages: list[dict], *, max_chars: int | None = None) -> None:
|
||||||
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
||||||
|
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||||
|
formatted = truncate_text(self._format_messages(messages), limit)
|
||||||
self.append_history(
|
self.append_history(
|
||||||
f"[RAW] {len(messages)} messages\n"
|
f"[RAW] {len(messages)} messages\n"
|
||||||
f"{self._format_messages(messages)}"
|
f"{formatted}"
|
||||||
)
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Memory consolidation degraded: raw-archived {} messages", len(messages)
|
"Memory consolidation degraded: raw-archived {} messages", len(messages)
|
||||||
@@ -390,11 +393,13 @@ class MemoryStore:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_RAW_ARCHIVE_MAX_CHARS = 16_000 # cap raw_archive entries to avoid bloating history.jsonl
|
||||||
|
|
||||||
|
|
||||||
class Consolidator:
|
class Consolidator:
|
||||||
"""Lightweight consolidation: summarizes evicted messages into history.jsonl."""
|
"""Lightweight consolidation: summarizes evicted messages into history.jsonl."""
|
||||||
|
|
||||||
_MAX_CONSOLIDATION_ROUNDS = 5
|
_MAX_CONSOLIDATION_ROUNDS = 5
|
||||||
_MAX_CHUNK_MESSAGES = 60 # hard cap per consolidation round
|
|
||||||
|
|
||||||
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
|
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
|
||||||
|
|
||||||
@@ -447,22 +452,6 @@ class Consolidator:
|
|||||||
|
|
||||||
return last_boundary
|
return last_boundary
|
||||||
|
|
||||||
def _cap_consolidation_boundary(
|
|
||||||
self,
|
|
||||||
session: Session,
|
|
||||||
end_idx: int,
|
|
||||||
) -> int | None:
|
|
||||||
"""Clamp the chunk size without breaking the user-turn boundary."""
|
|
||||||
start = session.last_consolidated
|
|
||||||
if end_idx - start <= self._MAX_CHUNK_MESSAGES:
|
|
||||||
return end_idx
|
|
||||||
|
|
||||||
capped_end = start + self._MAX_CHUNK_MESSAGES
|
|
||||||
for idx in range(capped_end, start, -1):
|
|
||||||
if session.messages[idx].get("role") == "user":
|
|
||||||
return idx
|
|
||||||
return None
|
|
||||||
|
|
||||||
def estimate_session_prompt_tokens(
|
def estimate_session_prompt_tokens(
|
||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -486,6 +475,25 @@ class Consolidator:
|
|||||||
self._get_tool_definitions(),
|
self._get_tool_definitions(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _input_token_budget(self) -> int:
|
||||||
|
"""Available input token budget for consolidation LLM."""
|
||||||
|
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
||||||
|
|
||||||
|
def _truncate_to_token_budget(self, text: str) -> str:
|
||||||
|
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
||||||
|
budget = self._input_token_budget
|
||||||
|
if budget <= 0:
|
||||||
|
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
||||||
|
try:
|
||||||
|
enc = tiktoken.get_encoding("cl100k_base")
|
||||||
|
tokens = enc.encode(text)
|
||||||
|
if len(tokens) <= budget:
|
||||||
|
return text
|
||||||
|
return enc.decode(tokens[:budget]) + "\n... (truncated)"
|
||||||
|
except Exception:
|
||||||
|
return truncate_text(text, budget * 4)
|
||||||
|
|
||||||
async def archive(self, messages: list[dict]) -> str | None:
|
async def archive(self, messages: list[dict]) -> str | None:
|
||||||
"""Summarize messages via LLM and append to history.jsonl.
|
"""Summarize messages via LLM and append to history.jsonl.
|
||||||
|
|
||||||
@@ -495,6 +503,7 @@ class Consolidator:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
formatted = MemoryStore._format_messages(messages)
|
formatted = MemoryStore._format_messages(messages)
|
||||||
|
formatted = self._truncate_to_token_budget(formatted)
|
||||||
response = await self.provider.chat_with_retry(
|
response = await self.provider.chat_with_retry(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
messages=[
|
messages=[
|
||||||
@@ -536,7 +545,7 @@ class Consolidator:
|
|||||||
|
|
||||||
lock = self.get_lock(session.key)
|
lock = self.get_lock(session.key)
|
||||||
async with lock:
|
async with lock:
|
||||||
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
budget = self._input_token_budget
|
||||||
target = budget // 2
|
target = budget // 2
|
||||||
try:
|
try:
|
||||||
estimated, source = self.estimate_session_prompt_tokens(
|
estimated, source = self.estimate_session_prompt_tokens(
|
||||||
@@ -575,14 +584,6 @@ class Consolidator:
|
|||||||
break
|
break
|
||||||
|
|
||||||
end_idx = boundary[0]
|
end_idx = boundary[0]
|
||||||
end_idx = self._cap_consolidation_boundary(session, end_idx)
|
|
||||||
if end_idx is None:
|
|
||||||
logger.debug(
|
|
||||||
"Token consolidation: no capped boundary for {} (round {})",
|
|
||||||
session.key,
|
|
||||||
round_num,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
chunk = session.messages[session.last_consolidated:end_idx]
|
chunk = session.messages[session.last_consolidated:end_idx]
|
||||||
if not chunk:
|
if not chunk:
|
||||||
|
|||||||
+147
-35
@@ -308,6 +308,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
|
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
|
||||||
self._bot_open_id: str | None = None
|
self._bot_open_id: str | None = None
|
||||||
|
self._background_tasks: set[asyncio.Task] = set()
|
||||||
|
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any:
|
def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any:
|
||||||
@@ -549,8 +551,11 @@ class FeishuChannel(BaseChannel):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
|
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
|
||||||
"""
|
"""Add a reaction emoji to a message.
|
||||||
Add a reaction emoji to a message (non-blocking).
|
|
||||||
|
Returns the reaction_id on success, None on failure.
|
||||||
|
When called via a tracked background task, the returned reaction_id
|
||||||
|
is stored in ``_reaction_ids`` for later cleanup by ``send_delta``.
|
||||||
|
|
||||||
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
|
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
|
||||||
"""
|
"""
|
||||||
@@ -594,6 +599,30 @@ class FeishuChannel(BaseChannel):
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
|
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
|
||||||
|
|
||||||
|
def _on_background_task_done(self, task: asyncio.Task) -> None:
|
||||||
|
"""Callback: remove from tracking set and log unhandled exceptions."""
|
||||||
|
self._background_tasks.discard(task)
|
||||||
|
if task.cancelled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
task.result()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Background task failed: {}", exc)
|
||||||
|
|
||||||
|
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
|
||||||
|
"""Callback: store reaction_id after background add-reaction completes."""
|
||||||
|
if task.cancelled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
reaction_id = task.result()
|
||||||
|
if reaction_id:
|
||||||
|
self._reaction_ids[message_id] = reaction_id
|
||||||
|
except Exception:
|
||||||
|
pass # already logged by _on_background_task_done
|
||||||
|
# Trim cache to prevent unbounded growth
|
||||||
|
if len(self._reaction_ids) > 500:
|
||||||
|
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
||||||
|
|
||||||
# Regex to match markdown tables (header + separator + data rows)
|
# Regex to match markdown tables (header + separator + data rows)
|
||||||
_TABLE_RE = re.compile(
|
_TABLE_RE = re.compile(
|
||||||
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
|
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
|
||||||
@@ -1101,17 +1130,23 @@ class FeishuChannel(BaseChannel):
|
|||||||
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
|
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool:
|
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
|
||||||
"""Reply to an existing Feishu message using the Reply API (synchronous)."""
|
"""Reply to an existing Feishu message using the Reply API (synchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reply_in_thread: If True, reply as a thread/topic message
|
||||||
|
in the Feishu client.
|
||||||
|
"""
|
||||||
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
|
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
body_builder = ReplyMessageRequestBody.builder().msg_type(msg_type).content(content)
|
||||||
|
if reply_in_thread:
|
||||||
|
body_builder = body_builder.reply_in_thread(True)
|
||||||
request = (
|
request = (
|
||||||
ReplyMessageRequest.builder()
|
ReplyMessageRequest.builder()
|
||||||
.message_id(parent_message_id)
|
.message_id(parent_message_id)
|
||||||
.request_body(
|
.request_body(body_builder.build())
|
||||||
ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()
|
|
||||||
)
|
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
response = self._client.im.v1.message.reply(request)
|
response = self._client.im.v1.message.reply(request)
|
||||||
@@ -1166,8 +1201,19 @@ class FeishuChannel(BaseChannel):
|
|||||||
logger.error("Error sending Feishu {} message: {}", msg_type, e)
|
logger.error("Error sending Feishu {} message: {}", msg_type, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _create_streaming_card_sync(self, receive_id_type: str, chat_id: str) -> str | None:
|
def _create_streaming_card_sync(
|
||||||
"""Create a CardKit streaming card, send it to chat, return card_id."""
|
self,
|
||||||
|
receive_id_type: str,
|
||||||
|
chat_id: str,
|
||||||
|
reply_message_id: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Create a CardKit streaming card, send it to chat, return card_id.
|
||||||
|
|
||||||
|
When *reply_message_id* is provided the card is delivered via the
|
||||||
|
reply API (with reply_in_thread=True) so it lands inside the
|
||||||
|
originating thread / topic. Otherwise the plain create-message
|
||||||
|
API is used.
|
||||||
|
"""
|
||||||
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
||||||
|
|
||||||
card_json = {
|
card_json = {
|
||||||
@@ -1196,13 +1242,19 @@ class FeishuChannel(BaseChannel):
|
|||||||
return None
|
return None
|
||||||
card_id = getattr(response.data, "card_id", None)
|
card_id = getattr(response.data, "card_id", None)
|
||||||
if card_id:
|
if card_id:
|
||||||
message_id = self._send_message_sync(
|
card_content = json.dumps(
|
||||||
receive_id_type,
|
{"type": "card", "data": {"card_id": card_id}}, ensure_ascii=False
|
||||||
chat_id,
|
|
||||||
"interactive",
|
|
||||||
json.dumps({"type": "card", "data": {"card_id": card_id}}),
|
|
||||||
)
|
)
|
||||||
if message_id:
|
if reply_message_id:
|
||||||
|
sent = self._reply_message_sync(
|
||||||
|
reply_message_id, "interactive", card_content,
|
||||||
|
reply_in_thread=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sent = self._send_message_sync(
|
||||||
|
receive_id_type, chat_id, "interactive", card_content,
|
||||||
|
) is not None
|
||||||
|
if sent:
|
||||||
return card_id
|
return card_id
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Created streaming card {} but failed to send it to {}", card_id, chat_id
|
"Created streaming card {} but failed to send it to {}", card_id, chat_id
|
||||||
@@ -1292,7 +1344,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
_stream_end: Finalize the streaming card.
|
_stream_end: Finalize the streaming card.
|
||||||
_tool_hint: Delta is a formatted tool hint (for display only).
|
_tool_hint: Delta is a formatted tool hint (for display only).
|
||||||
message_id: Original message id (used with _stream_end for reaction cleanup).
|
message_id: Original message id (used with _stream_end for reaction cleanup).
|
||||||
reaction_id: Reaction id to remove on stream end.
|
chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards.
|
||||||
"""
|
"""
|
||||||
if not self._client:
|
if not self._client:
|
||||||
return
|
return
|
||||||
@@ -1302,10 +1354,13 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
# --- stream end: final update or fallback ---
|
# --- stream end: final update or fallback ---
|
||||||
if meta.get("_stream_end"):
|
if meta.get("_stream_end"):
|
||||||
if (message_id := meta.get("message_id")) and (reaction_id := meta.get("reaction_id")):
|
message_id = meta.get("message_id")
|
||||||
await self._remove_reaction(message_id, reaction_id)
|
if message_id:
|
||||||
|
reaction_id = self._reaction_ids.pop(message_id, None)
|
||||||
|
if reaction_id:
|
||||||
|
await self._remove_reaction(message_id, reaction_id)
|
||||||
# Add completion emoji if configured
|
# Add completion emoji if configured
|
||||||
if self.config.done_emoji and message_id:
|
if self.config.done_emoji:
|
||||||
await self._add_reaction(message_id, self.config.done_emoji)
|
await self._add_reaction(message_id, self.config.done_emoji)
|
||||||
|
|
||||||
buf = self._stream_bufs.pop(chat_id, None)
|
buf = self._stream_bufs.pop(chat_id, None)
|
||||||
@@ -1343,9 +1398,22 @@ class FeishuChannel(BaseChannel):
|
|||||||
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
await loop.run_in_executor(
|
# Fallback: reply via the Reply API for group chats.
|
||||||
None, self._send_message_sync, rid_type, chat_id, "interactive", card
|
# Target message_id — the Feishu API keeps the reply in
|
||||||
)
|
# the same topic automatically.
|
||||||
|
_f_msg = meta.get("message_id")
|
||||||
|
fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None
|
||||||
|
if fallback_msg_id:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, lambda: self._reply_message_sync(
|
||||||
|
fallback_msg_id, "interactive", card,
|
||||||
|
reply_in_thread=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync, rid_type, chat_id, "interactive", card
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- accumulate delta ---
|
# --- accumulate delta ---
|
||||||
@@ -1359,8 +1427,16 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if buf.card_id is None:
|
if buf.card_id is None:
|
||||||
|
# Send the streaming card as a reply for group chats so it
|
||||||
|
# lands inside the originating topic/thread. Always target
|
||||||
|
# message_id (the actual inbound message) — the Feishu Reply
|
||||||
|
# API keeps the response in the same topic automatically.
|
||||||
|
is_group = meta.get("chat_type", "group") == "group"
|
||||||
|
reply_msg_id = meta.get("message_id") if is_group else None
|
||||||
card_id = await loop.run_in_executor(
|
card_id = await loop.run_in_executor(
|
||||||
None, self._create_streaming_card_sync, rid_type, chat_id
|
None,
|
||||||
|
self._create_streaming_card_sync,
|
||||||
|
rid_type, chat_id, reply_msg_id,
|
||||||
)
|
)
|
||||||
if card_id:
|
if card_id:
|
||||||
buf.card_id = card_id
|
buf.card_id = card_id
|
||||||
@@ -1404,37 +1480,59 @@ class FeishuChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
# No active streaming card — send as a regular
|
# No active streaming card — send as a regular
|
||||||
# interactive card with the same 🔧 prefix style.
|
# interactive card with the same 🔧 prefix style.
|
||||||
|
# Use reply API for group chats so the hint stays in topic.
|
||||||
card = json.dumps(
|
card = json.dumps(
|
||||||
{"config": {"wide_screen_mode": True}, "elements": [
|
{"config": {"wide_screen_mode": True}, "elements": [
|
||||||
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
||||||
]},
|
]},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
await loop.run_in_executor(
|
_th_msg_id = msg.metadata.get("message_id")
|
||||||
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
|
_th_chat_type = msg.metadata.get("chat_type", "group")
|
||||||
)
|
if _th_msg_id and _th_chat_type == "group":
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, lambda: self._reply_message_sync(
|
||||||
|
_th_msg_id, "interactive", card,
|
||||||
|
reply_in_thread=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine whether the first message should quote the user's message.
|
# Determine whether the first message should quote the user's message.
|
||||||
# Only the very first send (media or text) in this call uses reply; subsequent
|
# Only the very first send (media or text) in this call uses reply; subsequent
|
||||||
# chunks/media fall back to plain create to avoid redundant quote bubbles.
|
# chunks/media fall back to plain create to avoid redundant quote bubbles.
|
||||||
|
# Always target message_id — the Feishu Reply API keeps replies in the
|
||||||
|
# same topic automatically when the target message is inside a topic.
|
||||||
reply_message_id: str | None = None
|
reply_message_id: str | None = None
|
||||||
|
_msg_id = msg.metadata.get("message_id")
|
||||||
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
|
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
|
||||||
reply_message_id = msg.metadata.get("message_id") or None
|
reply_message_id = _msg_id
|
||||||
# For topic group messages, always reply to keep context in thread
|
# For topic group messages, always reply to keep context in thread
|
||||||
elif msg.metadata.get("thread_id"):
|
elif msg.metadata.get("thread_id"):
|
||||||
reply_message_id = (
|
reply_message_id = _msg_id
|
||||||
msg.metadata.get("root_id") or msg.metadata.get("message_id") or None
|
|
||||||
)
|
|
||||||
|
|
||||||
first_send = True # tracks whether the reply has already been used
|
first_send = True # tracks whether the reply has already been used
|
||||||
|
|
||||||
def _do_send(m_type: str, content: str) -> None:
|
def _do_send(m_type: str, content: str) -> None:
|
||||||
"""Send via reply (first message) or create (subsequent)."""
|
"""Send via reply (first message) or create (subsequent).
|
||||||
|
|
||||||
|
For group chats the reply API always uses reply_in_thread=True.
|
||||||
|
The Feishu API automatically keeps replies inside existing
|
||||||
|
topics — reply_in_thread only creates a *new* topic when the
|
||||||
|
target message is a plain (non-topic) message.
|
||||||
|
"""
|
||||||
nonlocal first_send
|
nonlocal first_send
|
||||||
if reply_message_id and first_send:
|
if reply_message_id and first_send:
|
||||||
first_send = False
|
first_send = False
|
||||||
ok = self._reply_message_sync(reply_message_id, m_type, content)
|
chat_type = msg.metadata.get("chat_type", "group")
|
||||||
|
ok = self._reply_message_sync(
|
||||||
|
reply_message_id, m_type, content,
|
||||||
|
reply_in_thread=chat_type == "group",
|
||||||
|
)
|
||||||
if ok:
|
if ok:
|
||||||
return
|
return
|
||||||
# Fall back to regular send if reply fails
|
# Fall back to regular send if reply fails
|
||||||
@@ -1543,8 +1641,13 @@ class FeishuChannel(BaseChannel):
|
|||||||
logger.debug("Feishu: skipping group message (not mentioned)")
|
logger.debug("Feishu: skipping group message (not mentioned)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add reaction
|
# Add reaction (non-blocking — tracked background task)
|
||||||
reaction_id = await self._add_reaction(message_id, self.config.react_emoji)
|
task = asyncio.create_task(
|
||||||
|
self._add_reaction(message_id, self.config.react_emoji)
|
||||||
|
)
|
||||||
|
self._background_tasks.add(task)
|
||||||
|
task.add_done_callback(self._on_background_task_done)
|
||||||
|
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
|
||||||
|
|
||||||
# Parse content
|
# Parse content
|
||||||
content_parts = []
|
content_parts = []
|
||||||
@@ -1624,6 +1727,15 @@ class FeishuChannel(BaseChannel):
|
|||||||
if not content and not media_paths:
|
if not content and not media_paths:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Build topic-scoped session key for conversation isolation.
|
||||||
|
# Group chat: each topic gets its own session via root_id (replies
|
||||||
|
# inside a topic) or message_id (top-level messages start a new topic).
|
||||||
|
# Private chat: no override — same behavior as Telegram/Slack.
|
||||||
|
if chat_type == "group":
|
||||||
|
session_key = f"feishu:{chat_id}:{root_id or message_id}"
|
||||||
|
else:
|
||||||
|
session_key = None
|
||||||
|
|
||||||
# Forward to message bus
|
# Forward to message bus
|
||||||
reply_to = chat_id if chat_type == "group" else sender_id
|
reply_to = chat_id if chat_type == "group" else sender_id
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
@@ -1633,13 +1745,13 @@ class FeishuChannel(BaseChannel):
|
|||||||
media=media_paths,
|
media=media_paths,
|
||||||
metadata={
|
metadata={
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
"reaction_id": reaction_id,
|
|
||||||
"chat_type": chat_type,
|
"chat_type": chat_type,
|
||||||
"msg_type": msg_type,
|
"msg_type": msg_type,
|
||||||
"parent_id": parent_id,
|
"parent_id": parent_id,
|
||||||
"root_id": root_id,
|
"root_id": root_id,
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
},
|
},
|
||||||
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class ConversationRef:
|
|||||||
activity_id: str | None = None
|
activity_id: str | None = None
|
||||||
conversation_type: str | None = None
|
conversation_type: str | None = None
|
||||||
tenant_id: str | None = None
|
tenant_id: str | None = None
|
||||||
|
updated_at: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class MSTeamsChannel(BaseChannel):
|
class MSTeamsChannel(BaseChannel):
|
||||||
@@ -220,7 +221,6 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
token = await self._get_access_token()
|
token = await self._get_access_token()
|
||||||
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
|
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
|
||||||
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
|
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
|
||||||
url = f"{base_url}/{ref.activity_id}" if use_thread_reply else base_url
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -233,7 +233,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
payload["replyToId"] = ref.activity_id
|
payload["replyToId"] = ref.activity_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await self._http.post(url, headers=headers, json=payload)
|
resp = await self._http.post(base_url, headers=headers, json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
logger.info("MSTeams message sent to {}", ref.conversation_id)
|
logger.info("MSTeams message sent to {}", ref.conversation_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -289,7 +289,9 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
activity_id=activity_id or None,
|
activity_id=activity_id or None,
|
||||||
conversation_type=conversation_type or None,
|
conversation_type=conversation_type or None,
|
||||||
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
|
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
|
||||||
|
updated_at=time.time(),
|
||||||
)
|
)
|
||||||
|
|
||||||
self._save_refs()
|
self._save_refs()
|
||||||
|
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
@@ -310,10 +312,12 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
"""Extract the user-authored text from a Teams activity."""
|
"""Extract the user-authored text from a Teams activity."""
|
||||||
text = str(activity.get("text") or "")
|
text = str(activity.get("text") or "")
|
||||||
text = self._strip_possible_bot_mention(text)
|
text = self._strip_possible_bot_mention(text)
|
||||||
|
text = self._normalize_html_whitespace(text)
|
||||||
|
|
||||||
channel_data = activity.get("channelData") or {}
|
channel_data = activity.get("channelData") or {}
|
||||||
reply_to_id = str(activity.get("replyToId") or "").strip()
|
reply_to_id = str(activity.get("replyToId") or "").strip()
|
||||||
normalized_preview = html.unescape(text).replace("&rsquo", "’").strip()
|
normalized_preview = html.unescape(text).replace("&rsquo", "’").strip()
|
||||||
|
normalized_preview = normalized_preview.replace("\xa0", " ")
|
||||||
normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n")
|
normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
preview_lines = [line.strip() for line in normalized_preview.split("\n")]
|
preview_lines = [line.strip() for line in normalized_preview.split("\n")]
|
||||||
while preview_lines and not preview_lines[0]:
|
while preview_lines and not preview_lines[0]:
|
||||||
@@ -333,9 +337,15 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
|
cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
|
||||||
return cleaned.strip()
|
return cleaned.strip()
|
||||||
|
|
||||||
|
def _normalize_html_whitespace(self, text: str) -> str:
|
||||||
|
"""Normalize common HTML whitespace/entities from Teams into plain text spacing."""
|
||||||
|
normalized = html.unescape(text).replace("&rsquo", "’")
|
||||||
|
normalized = normalized.replace("\xa0", " ")
|
||||||
|
return normalized
|
||||||
|
|
||||||
def _normalize_teams_reply_quote(self, text: str) -> str:
|
def _normalize_teams_reply_quote(self, text: str) -> str:
|
||||||
"""Normalize Teams quoted replies into a compact structured form."""
|
"""Normalize Teams quoted replies into a compact structured form."""
|
||||||
cleaned = html.unescape(text).replace("&rsquo", "’").strip()
|
cleaned = self._normalize_html_whitespace(text).strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -494,6 +504,14 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
def _save_refs(self) -> None:
|
def _save_refs(self) -> None:
|
||||||
"""Persist conversation references."""
|
"""Persist conversation references."""
|
||||||
try:
|
try:
|
||||||
|
stale_keys = [
|
||||||
|
key
|
||||||
|
for key, ref in self._conversation_refs.items()
|
||||||
|
if self._is_stale_or_unsupported_ref(ref)
|
||||||
|
]
|
||||||
|
for key in stale_keys:
|
||||||
|
self._conversation_refs.pop(key, None)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
key: {
|
key: {
|
||||||
"service_url": ref.service_url,
|
"service_url": ref.service_url,
|
||||||
@@ -502,6 +520,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
"activity_id": ref.activity_id,
|
"activity_id": ref.activity_id,
|
||||||
"conversation_type": ref.conversation_type,
|
"conversation_type": ref.conversation_type,
|
||||||
"tenant_id": ref.tenant_id,
|
"tenant_id": ref.tenant_id,
|
||||||
|
"updated_at": ref.updated_at,
|
||||||
}
|
}
|
||||||
for key, ref in self._conversation_refs.items()
|
for key, ref in self._conversation_refs.items()
|
||||||
}
|
}
|
||||||
@@ -509,6 +528,21 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to save MSTeams conversation refs: {}", e)
|
logger.warning("Failed to save MSTeams conversation refs: {}", e)
|
||||||
|
|
||||||
|
def _is_stale_or_unsupported_ref(self, ref: ConversationRef) -> bool:
|
||||||
|
"""Reject unsupported refs and prune old refs."""
|
||||||
|
service_url = (ref.service_url or "").strip().lower()
|
||||||
|
conversation_type = (ref.conversation_type or "").strip().lower()
|
||||||
|
updated_at = ref.updated_at or 0.0
|
||||||
|
max_age_seconds = 30 * 24 * 60 * 60
|
||||||
|
|
||||||
|
if "webchat.botframework.com" in service_url:
|
||||||
|
return True
|
||||||
|
if conversation_type and conversation_type != "personal":
|
||||||
|
return True
|
||||||
|
if updated_at and updated_at < time.time() - max_age_seconds:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def _get_access_token(self) -> str:
|
async def _get_access_token(self) -> str:
|
||||||
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import pytest
|
|||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
from nanobot.agent.memory import Consolidator, MemoryStore, _RAW_ARCHIVE_MAX_CHARS
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -117,8 +117,8 @@ class TestConsolidatorTokenBudget:
|
|||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
await consolidator.maybe_consolidate_by_tokens(session)
|
||||||
consolidator.archive.assert_not_called()
|
consolidator.archive.assert_not_called()
|
||||||
|
|
||||||
async def test_chunk_cap_preserves_user_turn_boundary(self, consolidator):
|
async def test_large_chunk_archived_without_cap(self, consolidator):
|
||||||
"""Chunk cap should rewind to the last user boundary within the cap."""
|
"""Without chunk cap, the full range from pick_consolidation_boundary is archived."""
|
||||||
consolidator._SAFETY_BUFFER = 0
|
consolidator._SAFETY_BUFFER = 0
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.last_consolidated = 0
|
session.last_consolidated = 0
|
||||||
@@ -133,19 +133,19 @@ class TestConsolidatorTokenBudget:
|
|||||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||||
)
|
)
|
||||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
|
# Use real pick_consolidation_boundary — it will find boundary at idx=50
|
||||||
|
# (user message at 50, token budget met)
|
||||||
consolidator.archive = AsyncMock(return_value=True)
|
consolidator.archive = AsyncMock(return_value=True)
|
||||||
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
await consolidator.maybe_consolidate_by_tokens(session)
|
||||||
|
|
||||||
archived_chunk = consolidator.archive.await_args.args[0]
|
archived_chunk = consolidator.archive.await_args.args[0]
|
||||||
assert len(archived_chunk) == 50
|
# pick_consolidation_boundary returns (50, tokens) — user turn at idx 50
|
||||||
assert archived_chunk[0]["content"] == "m0"
|
assert archived_chunk[0]["content"] == "m0"
|
||||||
assert archived_chunk[-1]["content"] == "m49"
|
assert session.last_consolidated > 0
|
||||||
assert session.last_consolidated == 50
|
|
||||||
|
|
||||||
async def test_chunk_cap_skips_when_no_user_boundary_within_cap(self, consolidator):
|
async def test_boundary_respected_when_no_intermediate_user_turn(self, consolidator):
|
||||||
"""If the cap would cut mid-turn, consolidation should skip that round."""
|
"""When boundary points past a long tool chain, the full chunk is archived."""
|
||||||
consolidator._SAFETY_BUFFER = 0
|
consolidator._SAFETY_BUFFER = 0
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.last_consolidated = 0
|
session.last_consolidated = 0
|
||||||
@@ -157,11 +157,76 @@ class TestConsolidatorTokenBudget:
|
|||||||
}
|
}
|
||||||
for i in range(70)
|
for i in range(70)
|
||||||
]
|
]
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(1200, "tiktoken"))
|
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
|
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||||
|
)
|
||||||
consolidator.archive = AsyncMock(return_value=True)
|
consolidator.archive = AsyncMock(return_value=True)
|
||||||
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
await consolidator.maybe_consolidate_by_tokens(session)
|
||||||
|
|
||||||
consolidator.archive.assert_not_awaited()
|
consolidator.archive.assert_awaited_once()
|
||||||
assert session.last_consolidated == 0
|
# pick_consolidation_boundary finds the only boundary at idx=61
|
||||||
|
assert session.last_consolidated == 61
|
||||||
|
|
||||||
|
|
||||||
|
class TestRawArchiveTruncation:
|
||||||
|
"""raw_archive() must cap entry size to avoid bloating history.jsonl."""
|
||||||
|
|
||||||
|
def test_raw_archive_truncates_large_content(self, store):
|
||||||
|
"""Large messages should be truncated to _RAW_ARCHIVE_MAX_CHARS."""
|
||||||
|
big = "x" * 50_000
|
||||||
|
messages = [{"role": "user", "content": big}]
|
||||||
|
store.raw_archive(messages)
|
||||||
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert len(entries[0]["content"]) < 50_000
|
||||||
|
assert "[RAW]" in entries[0]["content"]
|
||||||
|
|
||||||
|
def test_raw_archive_preserves_small_content(self, store):
|
||||||
|
"""Small messages should not be truncated."""
|
||||||
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
store.raw_archive(messages)
|
||||||
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert "hello" in entries[0]["content"]
|
||||||
|
|
||||||
|
def test_raw_archive_custom_max_chars(self, store):
|
||||||
|
"""max_chars parameter should override default limit."""
|
||||||
|
messages = [{"role": "user", "content": "a" * 200}]
|
||||||
|
store.raw_archive(messages, max_chars=100)
|
||||||
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
|
assert len(entries[0]["content"]) < 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestArchiveTruncation:
|
||||||
|
"""archive() must truncate formatted text before sending to consolidation LLM."""
|
||||||
|
|
||||||
|
async def test_archive_truncates_large_formatted_text(self, consolidator, mock_provider, store):
|
||||||
|
"""Large formatted text should be truncated to token budget before LLM call."""
|
||||||
|
# context_window_tokens=1000, max_completion_tokens=100, _SAFETY_BUFFER=1024
|
||||||
|
# budget = 1000 - 100 - 1024 = -124 → fallback via truncate_text(budget*4)
|
||||||
|
big_messages = [{"role": "user", "content": "x" * 100_000}]
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
|
content="Summary of large input.", finish_reason="stop"
|
||||||
|
)
|
||||||
|
await consolidator.archive(big_messages)
|
||||||
|
|
||||||
|
call_args = mock_provider.chat_with_retry.call_args
|
||||||
|
user_content = call_args.kwargs["messages"][1]["content"]
|
||||||
|
# Should be significantly shorter than 100K
|
||||||
|
assert len(user_content) < 50_000
|
||||||
|
|
||||||
|
async def test_archive_truncates_with_small_token_budget(self, consolidator, mock_provider, store):
|
||||||
|
"""Small context window: truncation uses actual tokenizer count."""
|
||||||
|
consolidator.context_window_tokens = 500
|
||||||
|
big_messages = [{"role": "user", "content": "word " * 50_000}]
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
|
content="Summary.", finish_reason="stop"
|
||||||
|
)
|
||||||
|
await consolidator.archive(big_messages)
|
||||||
|
|
||||||
|
sent_messages = mock_provider.chat_with_retry.call_args.kwargs["messages"]
|
||||||
|
user_content = sent_messages[1]["content"]
|
||||||
|
# budget = 500 - 100 - 1024 = negative, fallback char-based
|
||||||
|
# Should be truncated
|
||||||
|
assert len(user_content) < 250_000
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Tests for subagent tool registration and wiring."""
|
"""Tests for subagent tool registration and wiring."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -51,3 +52,209 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
mgr.runner.run.assert_awaited_once()
|
mgr.runner.run.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||||
|
"""_drain_pending should block when no messages are available but sub-agents are still running."""
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session.manager import 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")
|
||||||
|
|
||||||
|
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||||
|
session = Session(key="test:drain-block")
|
||||||
|
injection_callback = None
|
||||||
|
|
||||||
|
# Capture the injection_callback that _run_agent_loop creates
|
||||||
|
original_run = loop.runner.run
|
||||||
|
|
||||||
|
async def fake_runner_run(spec):
|
||||||
|
nonlocal injection_callback
|
||||||
|
injection_callback = spec.injection_callback
|
||||||
|
|
||||||
|
# Simulate: first call to injection_callback should block because
|
||||||
|
# sub-agents are running and no messages are in the queue yet.
|
||||||
|
# We'll resolve this from a concurrent task.
|
||||||
|
return SimpleNamespace(
|
||||||
|
stop_reason="done",
|
||||||
|
final_content="done",
|
||||||
|
error=None,
|
||||||
|
tool_events=[],
|
||||||
|
messages=[],
|
||||||
|
usage={},
|
||||||
|
had_injections=False,
|
||||||
|
tools_used=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||||
|
|
||||||
|
# Register a running sub-agent in the SubagentManager for this session
|
||||||
|
async def _hang_forever():
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
hang_task = asyncio.create_task(_hang_forever())
|
||||||
|
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-drain-1")
|
||||||
|
loop.subagents._running_tasks["sub-drain-1"] = hang_task
|
||||||
|
|
||||||
|
# Run _run_agent_loop — this defines the _drain_pending closure
|
||||||
|
await loop._run_agent_loop(
|
||||||
|
[{"role": "user", "content": "test"}],
|
||||||
|
session=session,
|
||||||
|
channel="test",
|
||||||
|
chat_id="c1",
|
||||||
|
pending_queue=pending_queue,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert injection_callback is not None
|
||||||
|
|
||||||
|
# Now test the callback directly
|
||||||
|
# With sub-agents running and an empty queue, it should block
|
||||||
|
drain_task = asyncio.create_task(injection_callback())
|
||||||
|
|
||||||
|
# Give it a moment to enter the blocking wait
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# Should still be running (blocked on pending_queue.get())
|
||||||
|
assert not drain_task.done(), "drain should block while sub-agents are running"
|
||||||
|
|
||||||
|
# Now put a message in the queue (simulating sub-agent completion)
|
||||||
|
await pending_queue.put(InboundMessage(
|
||||||
|
sender_id="subagent",
|
||||||
|
channel="test",
|
||||||
|
chat_id="c1",
|
||||||
|
content="Sub-agent result",
|
||||||
|
media=None,
|
||||||
|
metadata={},
|
||||||
|
))
|
||||||
|
|
||||||
|
# Should unblock and return results
|
||||||
|
results = await asyncio.wait_for(drain_task, timeout=2.0)
|
||||||
|
assert len(results) >= 1
|
||||||
|
assert results[0]["role"] == "user"
|
||||||
|
assert "Sub-agent result" in str(results[0]["content"])
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
hang_task.cancel()
|
||||||
|
try:
|
||||||
|
await hang_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||||
|
"""_drain_pending should not block when no sub-agents are running."""
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
|
||||||
|
pending_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
injection_callback = None
|
||||||
|
|
||||||
|
async def fake_runner_run(spec):
|
||||||
|
nonlocal injection_callback
|
||||||
|
injection_callback = spec.injection_callback
|
||||||
|
return SimpleNamespace(
|
||||||
|
stop_reason="done",
|
||||||
|
final_content="done",
|
||||||
|
error=None,
|
||||||
|
tool_events=[],
|
||||||
|
messages=[],
|
||||||
|
usage={},
|
||||||
|
had_injections=False,
|
||||||
|
tools_used=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||||
|
|
||||||
|
await loop._run_agent_loop(
|
||||||
|
[{"role": "user", "content": "test"}],
|
||||||
|
session=None,
|
||||||
|
channel="test",
|
||||||
|
chat_id="c1",
|
||||||
|
pending_queue=pending_queue,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert injection_callback is not None
|
||||||
|
|
||||||
|
# With no sub-agents and empty queue, should return immediately
|
||||||
|
results = await asyncio.wait_for(injection_callback(), timeout=1.0)
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drain_pending_timeout(tmp_path):
|
||||||
|
"""_drain_pending should return empty after timeout when sub-agents hang."""
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session.manager import 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")
|
||||||
|
|
||||||
|
pending_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
session = Session(key="test:drain-timeout")
|
||||||
|
injection_callback = None
|
||||||
|
|
||||||
|
async def fake_runner_run(spec):
|
||||||
|
nonlocal injection_callback
|
||||||
|
injection_callback = spec.injection_callback
|
||||||
|
return SimpleNamespace(
|
||||||
|
stop_reason="done",
|
||||||
|
final_content="done",
|
||||||
|
error=None,
|
||||||
|
tool_events=[],
|
||||||
|
messages=[],
|
||||||
|
usage={},
|
||||||
|
had_injections=False,
|
||||||
|
tools_used=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||||
|
|
||||||
|
# Register a "running" sub-agent that will never complete
|
||||||
|
async def _hang_forever():
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
hang_task = asyncio.create_task(_hang_forever())
|
||||||
|
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-timeout-1")
|
||||||
|
loop.subagents._running_tasks["sub-timeout-1"] = hang_task
|
||||||
|
|
||||||
|
await loop._run_agent_loop(
|
||||||
|
[{"role": "user", "content": "test"}],
|
||||||
|
session=session,
|
||||||
|
channel="test",
|
||||||
|
chat_id="c1",
|
||||||
|
pending_queue=pending_queue,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert injection_callback is not None
|
||||||
|
|
||||||
|
# Patch the timeout to be very short for testing
|
||||||
|
with patch("nanobot.agent.loop.asyncio.wait_for") as mock_wait:
|
||||||
|
mock_wait.side_effect = asyncio.TimeoutError
|
||||||
|
results = await injection_callback()
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
hang_task.cancel()
|
||||||
|
try:
|
||||||
|
await hang_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -166,13 +166,14 @@ class TestStreamEndReactionCleanup:
|
|||||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||||
)
|
)
|
||||||
|
ch._reaction_ids["om_001"] = "rx_42"
|
||||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||||
ch._remove_reaction = AsyncMock()
|
ch._remove_reaction = AsyncMock()
|
||||||
|
|
||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1", "",
|
"oc_chat1", "",
|
||||||
metadata={"_stream_end": True, "message_id": "om_001", "reaction_id": "rx_42"},
|
metadata={"_stream_end": True, "message_id": "om_001"},
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
||||||
@@ -189,7 +190,7 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1", "",
|
"oc_chat1", "",
|
||||||
metadata={"_stream_end": True, "reaction_id": "rx_42"},
|
metadata={"_stream_end": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -26,13 +26,14 @@ from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _make_feishu_channel(reply_to_message: bool = False) -> FeishuChannel:
|
def _make_feishu_channel(reply_to_message: bool = False, group_policy: str = "mention") -> FeishuChannel:
|
||||||
config = FeishuConfig(
|
config = FeishuConfig(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
app_id="cli_test",
|
app_id="cli_test",
|
||||||
app_secret="secret",
|
app_secret="secret",
|
||||||
allow_from=["*"],
|
allow_from=["*"],
|
||||||
reply_to_message=reply_to_message,
|
reply_to_message=reply_to_message,
|
||||||
|
group_policy=group_policy,
|
||||||
)
|
)
|
||||||
channel = FeishuChannel(config, MessageBus())
|
channel = FeishuChannel(config, MessageBus())
|
||||||
channel._client = MagicMock()
|
channel._client = MagicMock()
|
||||||
@@ -443,3 +444,288 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
|
|||||||
|
|
||||||
channel._client.im.v1.message.get.assert_not_called()
|
channel._client.im.v1.message.get.assert_not_called()
|
||||||
assert len(captured) == 1
|
assert len(captured) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session key derivation tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_key_group_with_root_id_is_thread_scoped() -> None:
|
||||||
|
"""Group message with root_id gets a thread-scoped session key."""
|
||||||
|
channel = _make_feishu_channel(group_policy="open")
|
||||||
|
bus_spy = []
|
||||||
|
original_publish = channel.bus.publish_inbound
|
||||||
|
|
||||||
|
async def capture(msg):
|
||||||
|
bus_spy.append(msg)
|
||||||
|
await original_publish(msg)
|
||||||
|
|
||||||
|
channel.bus.publish_inbound = capture
|
||||||
|
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||||
|
channel.transcribe_audio = AsyncMock(return_value="")
|
||||||
|
channel._add_reaction = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
event = _make_feishu_event(
|
||||||
|
chat_type="group",
|
||||||
|
content='{"text": "hello"}',
|
||||||
|
root_id="om_root123",
|
||||||
|
message_id="om_child456",
|
||||||
|
)
|
||||||
|
await channel._on_message(event)
|
||||||
|
|
||||||
|
assert len(bus_spy) == 1
|
||||||
|
assert bus_spy[0].session_key == "feishu:oc_abc:om_root123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_key_group_no_root_id_uses_message_id() -> None:
|
||||||
|
"""Group message without root_id gets session keyed by message_id (per-message session)."""
|
||||||
|
channel = _make_feishu_channel(group_policy="open")
|
||||||
|
bus_spy = []
|
||||||
|
original_publish = channel.bus.publish_inbound
|
||||||
|
|
||||||
|
async def capture(msg):
|
||||||
|
bus_spy.append(msg)
|
||||||
|
await original_publish(msg)
|
||||||
|
|
||||||
|
channel.bus.publish_inbound = capture
|
||||||
|
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||||
|
channel.transcribe_audio = AsyncMock(return_value="")
|
||||||
|
channel._add_reaction = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
event = _make_feishu_event(
|
||||||
|
chat_type="group",
|
||||||
|
content='{"text": "hello"}',
|
||||||
|
root_id=None,
|
||||||
|
message_id="om_001",
|
||||||
|
)
|
||||||
|
await channel._on_message(event)
|
||||||
|
|
||||||
|
assert len(bus_spy) == 1
|
||||||
|
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_key_private_chat_no_override() -> None:
|
||||||
|
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
|
||||||
|
channel = _make_feishu_channel()
|
||||||
|
bus_spy = []
|
||||||
|
original_publish = channel.bus.publish_inbound
|
||||||
|
|
||||||
|
async def capture(msg):
|
||||||
|
bus_spy.append(msg)
|
||||||
|
await original_publish(msg)
|
||||||
|
|
||||||
|
channel.bus.publish_inbound = capture
|
||||||
|
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||||
|
channel.transcribe_audio = AsyncMock(return_value="")
|
||||||
|
channel._add_reaction = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
event = _make_feishu_event(
|
||||||
|
chat_type="p2p",
|
||||||
|
content='{"text": "hello"}',
|
||||||
|
root_id=None,
|
||||||
|
message_id="om_001",
|
||||||
|
)
|
||||||
|
await channel._on_message(event)
|
||||||
|
|
||||||
|
assert len(bus_spy) == 1
|
||||||
|
assert bus_spy[0].session_key_override is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reply_in_thread tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_uses_reply_in_thread_when_enabled() -> None:
|
||||||
|
"""When reply_to_message is True, reply includes reply_in_thread=True."""
|
||||||
|
channel = _make_feishu_channel(reply_to_message=True)
|
||||||
|
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_abc",
|
||||||
|
content="hello",
|
||||||
|
metadata={"message_id": "om_001"},
|
||||||
|
))
|
||||||
|
|
||||||
|
channel._client.im.v1.message.reply.assert_called_once()
|
||||||
|
call_args = channel._client.im.v1.message.reply.call_args
|
||||||
|
request = call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_without_reply_in_thread_when_disabled() -> None:
|
||||||
|
"""When reply_to_message is False, reply does NOT use reply_in_thread."""
|
||||||
|
channel = _make_feishu_channel(reply_to_message=False)
|
||||||
|
|
||||||
|
create_resp = MagicMock()
|
||||||
|
create_resp.success.return_value = True
|
||||||
|
channel._client.im.v1.message.create.return_value = create_resp
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_abc",
|
||||||
|
content="hello",
|
||||||
|
))
|
||||||
|
|
||||||
|
# No message_id in metadata → no reply attempt, direct create
|
||||||
|
channel._client.im.v1.message.create.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_keeps_fallback_when_reply_fails() -> None:
|
||||||
|
"""Even with reply_to_message=True, fallback to create on reply failure."""
|
||||||
|
channel = _make_feishu_channel(reply_to_message=True)
|
||||||
|
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = False
|
||||||
|
reply_resp.code = 99991400
|
||||||
|
reply_resp.msg = "rate limited"
|
||||||
|
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
create_resp = MagicMock()
|
||||||
|
create_resp.success.return_value = True
|
||||||
|
channel._client.im.v1.message.create.return_value = create_resp
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_abc",
|
||||||
|
content="hello",
|
||||||
|
metadata={"message_id": "om_001"},
|
||||||
|
))
|
||||||
|
|
||||||
|
channel._client.im.v1.message.reply.assert_called()
|
||||||
|
channel._client.im.v1.message.create.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_no_reply_in_thread_for_p2p_chat() -> None:
|
||||||
|
"""reply_in_thread should NOT be set for p2p chats (identified by chat_type)."""
|
||||||
|
channel = _make_feishu_channel(reply_to_message=True)
|
||||||
|
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_abc", # p2p chats also use oc_ prefix
|
||||||
|
content="hello",
|
||||||
|
metadata={"message_id": "om_001", "chat_type": "p2p"},
|
||||||
|
))
|
||||||
|
|
||||||
|
channel._client.im.v1.message.reply.assert_called_once()
|
||||||
|
call_args = channel._client.im.v1.message.reply.call_args
|
||||||
|
request = call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is not True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_uses_reply_in_thread_for_group_chat() -> None:
|
||||||
|
"""reply_in_thread should be True for group chats (identified by chat_type)."""
|
||||||
|
channel = _make_feishu_channel(reply_to_message=True)
|
||||||
|
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_abc",
|
||||||
|
content="hello",
|
||||||
|
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||||
|
))
|
||||||
|
|
||||||
|
channel._client.im.v1.message.reply.assert_called_once()
|
||||||
|
call_args = channel._client.im.v1.message.reply.call_args
|
||||||
|
request = call_args[0][0]
|
||||||
|
assert request.request_body.reply_in_thread is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_targets_message_id_when_in_topic() -> None:
|
||||||
|
"""When inbound message is inside a topic (root_id != message_id),
|
||||||
|
the reply should target the inbound message_id (not root_id).
|
||||||
|
The Feishu Reply API keeps the response in the same topic
|
||||||
|
automatically when the target message is already inside a topic."""
|
||||||
|
channel = _make_feishu_channel(reply_to_message=True)
|
||||||
|
|
||||||
|
reply_resp = MagicMock()
|
||||||
|
reply_resp.success.return_value = True
|
||||||
|
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="feishu",
|
||||||
|
chat_id="oc_abc",
|
||||||
|
content="hello",
|
||||||
|
metadata={
|
||||||
|
"message_id": "om_child456",
|
||||||
|
"chat_type": "group",
|
||||||
|
"root_id": "om_root123",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
channel._client.im.v1.message.reply.assert_called_once()
|
||||||
|
call_args = channel._client.im.v1.message.reply.call_args
|
||||||
|
request = call_args[0][0]
|
||||||
|
# Should reply to the inbound message_id, not the root
|
||||||
|
assert request.message_id == "om_child456"
|
||||||
|
assert request.request_body.reply_in_thread is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_reaction_added_stores_reaction_id() -> None:
|
||||||
|
"""_on_reaction_added stores the returned reaction_id in _reaction_ids."""
|
||||||
|
channel = _make_feishu_channel()
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
task = loop.create_task(asyncio.sleep(0, result="reaction_abc"))
|
||||||
|
loop.run_until_complete(task)
|
||||||
|
channel._on_reaction_added("om_001", task)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
assert channel._reaction_ids["om_001"] == "reaction_abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_reaction_added_skips_none_result() -> None:
|
||||||
|
"""_on_reaction_added does not store None results."""
|
||||||
|
channel = _make_feishu_channel()
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
task = loop.create_task(asyncio.sleep(0, result=None))
|
||||||
|
loop.run_until_complete(task)
|
||||||
|
channel._on_reaction_added("om_001", task)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
assert "om_001" not in channel._reaction_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_background_task_done_removes_from_set() -> None:
|
||||||
|
"""_on_background_task_done removes task from tracking set."""
|
||||||
|
channel = _make_feishu_channel()
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
async def _fail():
|
||||||
|
raise RuntimeError("test failure")
|
||||||
|
|
||||||
|
task = loop.create_task(_fail())
|
||||||
|
channel._background_tasks.add(task)
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(task)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # expected
|
||||||
|
channel._on_background_task_done(task)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
assert task not in channel._background_tasks
|
||||||
|
|||||||
+46
-2
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -260,6 +261,17 @@ def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel):
|
|||||||
assert ch._sanitize_inbound_text(activity) == "normal inline message"
|
assert ch._sanitize_inbound_text(activity) == "normal inline message"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_inbound_text_normalizes_nbsp_entities(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"text": "Hello from Teams",
|
||||||
|
"channelData": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ch._sanitize_inbound_text(activity) == "Hello from Teams"
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_inbound_text_normalizes_reply_wrapper_without_reply_metadata(make_channel):
|
def test_sanitize_inbound_text_normalizes_reply_wrapper_without_reply_metadata(make_channel):
|
||||||
ch = make_channel()
|
ch = make_channel()
|
||||||
|
|
||||||
@@ -371,7 +383,7 @@ async def test_get_access_token_uses_configured_tenant(make_channel):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel):
|
async def test_send_posts_to_conversation_with_reply_to_id_when_reply_in_thread_enabled(make_channel):
|
||||||
ch = make_channel(replyInThread=True)
|
ch = make_channel(replyInThread=True)
|
||||||
fake_http = FakeHttpClient()
|
fake_http = FakeHttpClient()
|
||||||
ch._http = fake_http
|
ch._http = fake_http
|
||||||
@@ -387,7 +399,7 @@ async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channe
|
|||||||
|
|
||||||
assert len(fake_http.calls) == 1
|
assert len(fake_http.calls) == 1
|
||||||
url, kwargs = fake_http.calls[0]
|
url, kwargs = fake_http.calls[0]
|
||||||
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities/activity-1"
|
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
|
||||||
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||||
assert kwargs["json"]["text"] == "Reply text"
|
assert kwargs["json"]["text"] == "Reply text"
|
||||||
assert kwargs["json"]["replyToId"] == "activity-1"
|
assert kwargs["json"]["replyToId"] == "activity-1"
|
||||||
@@ -551,6 +563,38 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa
|
|||||||
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"]
|
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
now = time.time()
|
||||||
|
ch._conversation_refs = {
|
||||||
|
"teams-good": ConversationRef(
|
||||||
|
service_url="https://smba.trafficmanager.net/amer/",
|
||||||
|
conversation_id="teams-good",
|
||||||
|
conversation_type="personal",
|
||||||
|
updated_at=now,
|
||||||
|
),
|
||||||
|
"webchat-bad": ConversationRef(
|
||||||
|
service_url="https://webchat.botframework.com/",
|
||||||
|
conversation_id="webchat-bad",
|
||||||
|
conversation_type=None,
|
||||||
|
updated_at=now,
|
||||||
|
),
|
||||||
|
"teams-stale": ConversationRef(
|
||||||
|
service_url="https://smba.trafficmanager.net/amer/",
|
||||||
|
conversation_id="teams-stale",
|
||||||
|
conversation_type="personal",
|
||||||
|
updated_at=now - (31 * 24 * 60 * 60),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
ch._save_refs()
|
||||||
|
|
||||||
|
assert set(ch._conversation_refs) == {"teams-good"}
|
||||||
|
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
|
||||||
|
assert set(saved) == {"teams-good"}
|
||||||
|
assert saved["teams-good"]["updated_at"] == pytest.approx(now)
|
||||||
|
|
||||||
|
|
||||||
def test_msteams_default_config_includes_restart_notify_fields():
|
def test_msteams_default_config_includes_restart_notify_fields():
|
||||||
cfg = MSTeamsChannel.default_config()
|
cfg = MSTeamsChannel.default_config()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user