mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08c5ce95f2 | ||
|
|
d5122f6df8 | ||
|
|
1b231eb69f | ||
|
|
91d5f14fbd | ||
|
|
3ece7256d1 | ||
|
|
a0e97e360e | ||
|
|
934372d90b | ||
|
|
9eff9a70bb | ||
|
|
5c2c1bb9ef | ||
|
|
d40ce81a3d | ||
|
|
8a646d9aec | ||
|
|
93bcb0a649 | ||
|
|
da0ebc64fb | ||
|
|
9bf7f3b420 | ||
|
|
0932189860 | ||
|
|
512bf59b3c | ||
|
|
ef8bbab7b3 | ||
|
|
2e419f9ba2 | ||
|
|
88c619901e | ||
|
|
28c42628b0 | ||
|
|
f6a417e77d | ||
|
|
123d69bfb7 | ||
|
|
1826ab44fa | ||
|
|
a4a197fea5 | ||
|
|
bc3d734df5 | ||
|
|
1835f94d8e | ||
|
|
f5b8ee9f78 | ||
|
|
c51b653154 | ||
|
|
51cb260f05 | ||
|
|
e4fa58ef45 | ||
|
|
f2848b9b94 | ||
|
|
e49b56525b | ||
|
|
3454efcd98 | ||
|
|
c7057cb3bf | ||
|
|
8301a3a741 | ||
|
|
1826bfd05a | ||
|
|
197ecb02ca | ||
|
|
74d314d3ef | ||
|
|
375b1f0328 | ||
|
|
a7caee1186 |
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
|
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
|
||||||
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
|
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
|
||||||
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
||||||
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
|
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ IMAP_PASSWORD=your-password-here
|
|||||||
## Providers
|
## Providers
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead — the API key is picked from the matching provider config.
|
> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead, and optionally set `"transcriptionLanguage": "en"` (or another ISO-639-1 code) for more accurate transcription. The API key is picked from the matching provider config.
|
||||||
> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
|
> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
|
||||||
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
|
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
|
||||||
> - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
|
> - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
|
||||||
@@ -440,6 +440,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
|||||||
"sendToolHints": false,
|
"sendToolHints": false,
|
||||||
"sendMaxRetries": 3,
|
"sendMaxRetries": 3,
|
||||||
"transcriptionProvider": "groq",
|
"transcriptionProvider": "groq",
|
||||||
|
"transcriptionLanguage": null,
|
||||||
"telegram": { ... }
|
"telegram": { ... }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,6 +452,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
|||||||
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
||||||
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
|
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
|
||||||
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
|
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
|
||||||
|
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
|
||||||
|
|
||||||
### Retry Behavior
|
### Retry Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class BaseChannel(ABC):
|
|||||||
transcription_provider: str = "groq"
|
transcription_provider: str = "groq"
|
||||||
transcription_api_key: str = ""
|
transcription_api_key: str = ""
|
||||||
transcription_api_base: str = ""
|
transcription_api_base: str = ""
|
||||||
|
transcription_language: str | None = None
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
"""
|
"""
|
||||||
@@ -48,12 +49,14 @@ class BaseChannel(ABC):
|
|||||||
provider = OpenAITranscriptionProvider(
|
provider = OpenAITranscriptionProvider(
|
||||||
api_key=self.transcription_api_key,
|
api_key=self.transcription_api_key,
|
||||||
api_base=self.transcription_api_base or None,
|
api_base=self.transcription_api_base or None,
|
||||||
|
language=self.transcription_language or None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
from nanobot.providers.transcription import GroqTranscriptionProvider
|
from nanobot.providers.transcription import GroqTranscriptionProvider
|
||||||
provider = GroqTranscriptionProvider(
|
provider = GroqTranscriptionProvider(
|
||||||
api_key=self.transcription_api_key,
|
api_key=self.transcription_api_key,
|
||||||
api_base=self.transcription_api_base or None,
|
api_base=self.transcription_api_base or None,
|
||||||
|
language=self.transcription_language or None,
|
||||||
)
|
)
|
||||||
return await provider.transcribe(file_path)
|
return await provider.transcribe(file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+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:
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class ChannelManager:
|
|||||||
transcription_provider = self.config.channels.transcription_provider
|
transcription_provider = self.config.channels.transcription_provider
|
||||||
transcription_key = self._resolve_transcription_key(transcription_provider)
|
transcription_key = self._resolve_transcription_key(transcription_provider)
|
||||||
transcription_base = self._resolve_transcription_base(transcription_provider)
|
transcription_base = self._resolve_transcription_base(transcription_provider)
|
||||||
|
transcription_language = self.config.channels.transcription_language
|
||||||
|
|
||||||
for name, cls in discover_all().items():
|
for name, cls in discover_all().items():
|
||||||
section = getattr(self.config.channels, name, None)
|
section = getattr(self.config.channels, name, None)
|
||||||
@@ -88,6 +89,7 @@ class ChannelManager:
|
|||||||
channel.transcription_provider = transcription_provider
|
channel.transcription_provider = transcription_provider
|
||||||
channel.transcription_api_key = transcription_key
|
channel.transcription_api_key = transcription_key
|
||||||
channel.transcription_api_base = transcription_base
|
channel.transcription_api_base = transcription_base
|
||||||
|
channel.transcription_language = transcription_language
|
||||||
self.channels[name] = channel
|
self.channels[name] = channel
|
||||||
logger.info("{} channel enabled", cls.display_name)
|
logger.info("{} channel enabled", cls.display_name)
|
||||||
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."""
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ def _make_console() -> Console:
|
|||||||
def _render_interactive_ansi(render_fn) -> str:
|
def _render_interactive_ansi(render_fn) -> str:
|
||||||
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
|
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
|
||||||
ansi_console = Console(
|
ansi_console = Console(
|
||||||
force_terminal=True,
|
force_terminal=sys.stdout.isatty(),
|
||||||
color_system=console.color_system or "standard",
|
color_system=console.color_system or "standard",
|
||||||
width=console.width,
|
width=console.width,
|
||||||
)
|
)
|
||||||
@@ -946,6 +946,12 @@ def _run_gateway(
|
|||||||
cron.stop()
|
cron.stop()
|
||||||
agent.stop()
|
agent.stop()
|
||||||
await channels.stop_all()
|
await channels.stop_all()
|
||||||
|
# Flush all cached sessions to durable storage before exit.
|
||||||
|
# This prevents data loss on filesystems with write-back
|
||||||
|
# caching (rclone VFS, NFS, FUSE mounts, etc.).
|
||||||
|
flushed = agent.sessions.flush_all()
|
||||||
|
if flushed:
|
||||||
|
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class ChannelsConfig(Base):
|
|||||||
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
||||||
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
|
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
|
||||||
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
|
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
|
||||||
|
transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
|
||||||
|
|
||||||
|
|
||||||
class DreamConfig(Base):
|
class DreamConfig(Base):
|
||||||
|
|||||||
@@ -387,14 +387,28 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
kwargs.update(overrides)
|
kwargs.update(overrides)
|
||||||
break
|
break
|
||||||
|
|
||||||
if reasoning_effort:
|
# Normalize reasoning_effort into a semantic form (OpenAI vocab)
|
||||||
kwargs["reasoning_effort"] = reasoning_effort
|
# used for internal decisions, and a wire form actually sent out.
|
||||||
|
# "minimum" is accepted as a DashScope-native alias for "minimal".
|
||||||
|
semantic_effort: str | None = None
|
||||||
|
if isinstance(reasoning_effort, str):
|
||||||
|
semantic_effort = reasoning_effort.lower()
|
||||||
|
if semantic_effort == "minimum":
|
||||||
|
semantic_effort = "minimal"
|
||||||
|
|
||||||
|
wire_effort = reasoning_effort
|
||||||
|
if spec and spec.name == "dashscope" and semantic_effort == "minimal":
|
||||||
|
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
|
||||||
|
wire_effort = "minimum"
|
||||||
|
|
||||||
|
if wire_effort:
|
||||||
|
kwargs["reasoning_effort"] = wire_effort
|
||||||
|
|
||||||
# Provider-specific thinking parameters.
|
# Provider-specific thinking parameters.
|
||||||
# Only sent when reasoning_effort is explicitly configured so that
|
# Only sent when reasoning_effort is explicitly configured so that
|
||||||
# the provider default is preserved otherwise.
|
# the provider default is preserved otherwise.
|
||||||
if spec and reasoning_effort is not None:
|
if spec and reasoning_effort is not None:
|
||||||
thinking_enabled = reasoning_effort.lower() != "minimal"
|
thinking_enabled = semantic_effort != "minimal"
|
||||||
extra: dict[str, Any] | None = None
|
extra: dict[str, Any] | None = None
|
||||||
if spec.name == "dashscope":
|
if spec.name == "dashscope":
|
||||||
extra = {"enable_thinking": thinking_enabled}
|
extra = {"enable_thinking": thinking_enabled}
|
||||||
@@ -415,7 +429,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
||||||
# identically to bare names like "kimi-k2.5".
|
# identically to bare names like "kimi-k2.5".
|
||||||
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
|
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
|
||||||
thinking_enabled = reasoning_effort.lower() != "minimal"
|
thinking_enabled = semantic_effort != "minimal"
|
||||||
kwargs.setdefault("extra_body", {}).update(
|
kwargs.setdefault("extra_body", {}).update(
|
||||||
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,13 +10,19 @@ from loguru import logger
|
|||||||
class OpenAITranscriptionProvider:
|
class OpenAITranscriptionProvider:
|
||||||
"""Voice transcription provider using OpenAI's Whisper API."""
|
"""Voice transcription provider using OpenAI's Whisper API."""
|
||||||
|
|
||||||
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_base: str | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
):
|
||||||
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||||
self.api_url = (
|
self.api_url = (
|
||||||
api_base
|
api_base
|
||||||
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
|
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
|
||||||
or "https://api.openai.com/v1/audio/transcriptions"
|
or "https://api.openai.com/v1/audio/transcriptions"
|
||||||
)
|
)
|
||||||
|
self.language = language or None
|
||||||
|
|
||||||
async def transcribe(self, file_path: str | Path) -> str:
|
async def transcribe(self, file_path: str | Path) -> str:
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
@@ -30,6 +36,8 @@ class OpenAITranscriptionProvider:
|
|||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
files = {"file": (path.name, f), "model": (None, "whisper-1")}
|
files = {"file": (path.name, f), "model": (None, "whisper-1")}
|
||||||
|
if self.language:
|
||||||
|
files["language"] = (None, self.language)
|
||||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
self.api_url, headers=headers, files=files, timeout=60.0,
|
self.api_url, headers=headers, files=files, timeout=60.0,
|
||||||
@@ -48,9 +56,15 @@ class GroqTranscriptionProvider:
|
|||||||
Groq offers extremely fast transcription with a generous free tier.
|
Groq offers extremely fast transcription with a generous free tier.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_base: str | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
):
|
||||||
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
||||||
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
|
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||||
|
self.language = language or None
|
||||||
|
|
||||||
async def transcribe(self, file_path: str | Path) -> str:
|
async def transcribe(self, file_path: str | Path) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -78,6 +92,8 @@ class GroqTranscriptionProvider:
|
|||||||
"file": (path.name, f),
|
"file": (path.name, f),
|
||||||
"model": (None, "whisper-large-v3"),
|
"model": (None, "whisper-large-v3"),
|
||||||
}
|
}
|
||||||
|
if self.language:
|
||||||
|
files["language"] = (None, self.language)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,8 +262,16 @@ class SessionManager:
|
|||||||
"messages": session.messages,
|
"messages": session.messages,
|
||||||
}
|
}
|
||||||
|
|
||||||
def save(self, session: Session) -> None:
|
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
"""Save a session to disk atomically."""
|
"""Save a session to disk atomically.
|
||||||
|
|
||||||
|
When *fsync* is ``True`` the final file and its parent directory are
|
||||||
|
explicitly flushed to durable storage. This is intentionally off by
|
||||||
|
default (the OS page-cache is sufficient for normal operation) but
|
||||||
|
should be enabled during graceful shutdown so that filesystems with
|
||||||
|
write-back caching (e.g. rclone VFS, NFS, FUSE mounts) do not lose
|
||||||
|
the most recent writes.
|
||||||
|
"""
|
||||||
path = self._get_session_path(session.key)
|
path = self._get_session_path(session.key)
|
||||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||||
|
|
||||||
@@ -280,14 +288,47 @@ class SessionManager:
|
|||||||
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
||||||
for msg in session.messages:
|
for msg in session.messages:
|
||||||
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||||
|
if fsync:
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
|
||||||
os.replace(tmp_path, path)
|
os.replace(tmp_path, path)
|
||||||
|
|
||||||
|
if fsync:
|
||||||
|
# fsync the directory so the rename is durable.
|
||||||
|
# On Windows, opening a directory with O_RDONLY raises
|
||||||
|
# PermissionError — skip the dir sync there (NTFS
|
||||||
|
# journals metadata synchronously).
|
||||||
|
try:
|
||||||
|
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
except PermissionError:
|
||||||
|
pass # Windows — directory fsync not supported
|
||||||
except BaseException:
|
except BaseException:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self._cache[session.key] = session
|
self._cache[session.key] = session
|
||||||
|
|
||||||
|
def flush_all(self) -> int:
|
||||||
|
"""Re-save every cached session with fsync for durable shutdown.
|
||||||
|
|
||||||
|
Returns the number of sessions flushed. Errors on individual
|
||||||
|
sessions are logged but do not prevent other sessions from being
|
||||||
|
flushed.
|
||||||
|
"""
|
||||||
|
flushed = 0
|
||||||
|
for key, session in list(self._cache.items()):
|
||||||
|
try:
|
||||||
|
self.save(session, fsync=True)
|
||||||
|
flushed += 1
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to flush session {}", key, exc_info=True)
|
||||||
|
return flushed
|
||||||
|
|
||||||
def invalidate(self, key: str) -> None:
|
def invalidate(self, key: str) -> None:
|
||||||
"""Remove a session from the in-memory cache."""
|
"""Remove a session from the in-memory cache."""
|
||||||
self._cache.pop(key, None)
|
self._cache.pop(key, None)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from nanobot.channels.manager import ChannelManager
|
|||||||
from nanobot.config.schema import ChannelsConfig
|
from nanobot.config.schema import ChannelsConfig
|
||||||
from nanobot.utils.restart import RestartNotice
|
from nanobot.utils.restart import RestartNotice
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -200,8 +199,8 @@ async def test_manager_propagates_groq_transcription_api_base_to_channels():
|
|||||||
fake_config = SimpleNamespace(
|
fake_config = SimpleNamespace(
|
||||||
channels=ChannelsConfig.model_validate({
|
channels=ChannelsConfig.model_validate({
|
||||||
"fakeplugin": {"enabled": True, "allowFrom": ["*"]},
|
"fakeplugin": {"enabled": True, "allowFrom": ["*"]},
|
||||||
|
"transcriptionLanguage": "en",
|
||||||
}),
|
}),
|
||||||
transcription_provider="groq",
|
|
||||||
providers=SimpleNamespace(
|
providers=SimpleNamespace(
|
||||||
groq=SimpleNamespace(api_key="groq-key", api_base="http://proxy.local/v1/audio/transcriptions"),
|
groq=SimpleNamespace(api_key="groq-key", api_base="http://proxy.local/v1/audio/transcriptions"),
|
||||||
openai=SimpleNamespace(api_key="openai-key", api_base="https://api.openai.com/v1/audio/transcriptions"),
|
openai=SimpleNamespace(api_key="openai-key", api_base="https://api.openai.com/v1/audio/transcriptions"),
|
||||||
@@ -223,6 +222,7 @@ async def test_manager_propagates_groq_transcription_api_base_to_channels():
|
|||||||
assert channel.transcription_provider == "groq"
|
assert channel.transcription_provider == "groq"
|
||||||
assert channel.transcription_api_key == "groq-key"
|
assert channel.transcription_api_key == "groq-key"
|
||||||
assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions"
|
assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions"
|
||||||
|
assert channel.transcription_language == "en"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -269,13 +269,15 @@ async def test_base_channel_passes_api_base_to_openai_transcription_provider():
|
|||||||
channel.transcription_provider = "openai"
|
channel.transcription_provider = "openai"
|
||||||
channel.transcription_api_key = "k"
|
channel.transcription_api_key = "k"
|
||||||
channel.transcription_api_base = "http://override/v1/audio/transcriptions"
|
channel.transcription_api_base = "http://override/v1/audio/transcriptions"
|
||||||
|
channel.transcription_language = "en"
|
||||||
|
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
class _StubOpenAI:
|
class _StubOpenAI:
|
||||||
def __init__(self, api_key=None, api_base=None):
|
def __init__(self, api_key=None, api_base=None, language=None):
|
||||||
captured["api_key"] = api_key
|
captured["api_key"] = api_key
|
||||||
captured["api_base"] = api_base
|
captured["api_base"] = api_base
|
||||||
|
captured["language"] = language
|
||||||
|
|
||||||
async def transcribe(self, file_path):
|
async def transcribe(self, file_path):
|
||||||
return "ok"
|
return "ok"
|
||||||
@@ -286,6 +288,7 @@ async def test_base_channel_passes_api_base_to_openai_transcription_provider():
|
|||||||
assert result == "ok"
|
assert result == "ok"
|
||||||
assert captured["api_key"] == "k"
|
assert captured["api_key"] == "k"
|
||||||
assert captured["api_base"] == "http://override/v1/audio/transcriptions"
|
assert captured["api_base"] == "http://override/v1/audio/transcriptions"
|
||||||
|
assert captured["language"] == "en"
|
||||||
|
|
||||||
|
|
||||||
def test_openai_transcription_provider_honors_api_base_argument():
|
def test_openai_transcription_provider_honors_api_base_argument():
|
||||||
@@ -300,10 +303,114 @@ def test_openai_transcription_provider_honors_api_base_argument():
|
|||||||
assert custom.api_url == "http://override/v1/audio/transcriptions"
|
assert custom.api_url == "http://override/v1/audio/transcriptions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_base_channel_passes_language_to_groq_transcription_provider():
|
||||||
|
"""BaseChannel.transcribe_audio must forward transcription_language to Groq."""
|
||||||
|
from nanobot.providers import transcription as transcription_mod
|
||||||
|
|
||||||
|
channel = _FakePlugin({"enabled": True, "allowFrom": ["*"]}, MessageBus())
|
||||||
|
channel.transcription_provider = "groq"
|
||||||
|
channel.transcription_api_key = "k"
|
||||||
|
channel.transcription_api_base = "http://override/v1/audio/transcriptions"
|
||||||
|
channel.transcription_language = "ko"
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _StubGroq:
|
||||||
|
def __init__(self, api_key=None, api_base=None, language=None):
|
||||||
|
captured["api_key"] = api_key
|
||||||
|
captured["api_base"] = api_base
|
||||||
|
captured["language"] = language
|
||||||
|
|
||||||
|
async def transcribe(self, file_path):
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
with patch.object(transcription_mod, "GroqTranscriptionProvider", _StubGroq):
|
||||||
|
result = await channel.transcribe_audio("/tmp/does-not-matter.wav")
|
||||||
|
|
||||||
|
assert result == "ok"
|
||||||
|
assert captured["api_key"] == "k"
|
||||||
|
assert captured["api_base"] == "http://override/v1/audio/transcriptions"
|
||||||
|
assert captured["language"] == "ko"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"text": "hello"}
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_async_client(captured: dict[str, object]):
|
||||||
|
"""Return an httpx.AsyncClient stub that records POST calls into *captured*."""
|
||||||
|
class _AsyncClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, url, headers=None, files=None, timeout=None):
|
||||||
|
captured["files"] = files
|
||||||
|
return _StubResponse()
|
||||||
|
|
||||||
|
return _AsyncClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"provider_cls,language",
|
||||||
|
[(_GroqProvider, "ko"), (_OpenAIProvider, "en")],
|
||||||
|
ids=["groq", "openai"],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcription_provider_includes_language(tmp_path, provider_cls, language):
|
||||||
|
"""Provider must include the 'language' field in multipart body when set."""
|
||||||
|
audio = tmp_path / "sample.wav"
|
||||||
|
audio.write_bytes(b"audio")
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
with patch("nanobot.providers.transcription.httpx.AsyncClient", return_value=_stub_async_client(captured)):
|
||||||
|
provider = provider_cls(api_key="k", language=language)
|
||||||
|
result = await provider.transcribe(audio)
|
||||||
|
|
||||||
|
assert result == "hello"
|
||||||
|
assert captured["files"]["language"] == (None, language)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"provider_cls",
|
||||||
|
[_GroqProvider, _OpenAIProvider],
|
||||||
|
ids=["groq", "openai"],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcription_provider_omits_language_when_none(tmp_path, provider_cls):
|
||||||
|
"""When language is not set, the 'language' key must be absent from the multipart body."""
|
||||||
|
audio = tmp_path / "sample.wav"
|
||||||
|
audio.write_bytes(b"audio")
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
with patch("nanobot.providers.transcription.httpx.AsyncClient", return_value=_stub_async_client(captured)):
|
||||||
|
provider = provider_cls(api_key="k")
|
||||||
|
result = await provider.transcribe(audio)
|
||||||
|
|
||||||
|
assert result == "hello"
|
||||||
|
assert "language" not in captured["files"]
|
||||||
|
|
||||||
|
|
||||||
def test_channels_login_uses_discovered_plugin_class(monkeypatch):
|
def test_channels_login_uses_discovered_plugin_class(monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from nanobot.cli.commands import app
|
from nanobot.cli.commands import app
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
@@ -329,9 +436,10 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
|
def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from nanobot.cli.commands import app
|
from nanobot.cli.commands import app
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
@@ -358,9 +466,10 @@ def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
|
def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from nanobot.cli.commands import app
|
from nanobot.cli.commands import app
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
@@ -455,6 +564,24 @@ def test_channels_config_send_max_retries_upper_bound():
|
|||||||
ChannelsConfig(send_max_retries=11)
|
ChannelsConfig(send_max_retries=11)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channels_config_transcription_language_pattern():
|
||||||
|
"""transcription_language must match ISO-639 format (2-3 lowercase letters) or be None."""
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
# Valid values
|
||||||
|
assert ChannelsConfig(transcription_language="en").transcription_language == "en"
|
||||||
|
assert ChannelsConfig(transcription_language="kor").transcription_language == "kor"
|
||||||
|
assert ChannelsConfig(transcription_language=None).transcription_language is None
|
||||||
|
|
||||||
|
# Invalid values
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ChannelsConfig(transcription_language="EN") # uppercase
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ChannelsConfig(transcription_language="english") # full word
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ChannelsConfig(transcription_language="en-US") # BCP 47 tag
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _send_with_retry
|
# _send_with_retry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -183,3 +183,22 @@ def test_make_console_force_terminal_false_when_stdout_is_not_tty():
|
|||||||
with patch.object(sys.stdout, "isatty", return_value=False):
|
with patch.object(sys.stdout, "isatty", return_value=False):
|
||||||
console = stream_mod._make_console()
|
console = stream_mod._make_console()
|
||||||
assert console._force_terminal is False
|
assert console._force_terminal is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_interactive_ansi_force_terminal_follows_isatty():
|
||||||
|
"""Mirror of _make_console: the capture console used to produce ANSI for
|
||||||
|
prompt_toolkit must also defer to sys.stdout.isatty(), otherwise cursor
|
||||||
|
escapes and spinner frames leak into piped output (#3265, #3370)."""
|
||||||
|
import sys
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def render_fn(c):
|
||||||
|
captured["console"] = c
|
||||||
|
|
||||||
|
with patch.object(sys.stdout, "isatty", return_value=True):
|
||||||
|
commands._render_interactive_ansi(render_fn)
|
||||||
|
assert captured["console"]._force_terminal is True
|
||||||
|
|
||||||
|
with patch.object(sys.stdout, "isatty", return_value=False):
|
||||||
|
commands._render_interactive_ansi(render_fn)
|
||||||
|
assert captured["console"]._force_terminal is False
|
||||||
|
|||||||
@@ -1288,10 +1288,15 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
class _FakeSessionManager:
|
||||||
|
def flush_all(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
class _FakeAgentLoop:
|
class _FakeAgentLoop:
|
||||||
def __init__(self, **_kwargs) -> None:
|
def __init__(self, **_kwargs) -> None:
|
||||||
self.model = "test-model"
|
self.model = "test-model"
|
||||||
self.dream = _FakeDream()
|
self.dream = _FakeDream()
|
||||||
|
self.sessions = _FakeSessionManager()
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|||||||
@@ -731,10 +731,25 @@ def test_dashscope_thinking_enabled_with_reasoning_effort() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_dashscope_thinking_disabled_for_minimal() -> None:
|
def test_dashscope_thinking_disabled_for_minimal() -> None:
|
||||||
|
"""'minimal' → wire 'minimum' + thinking off on DashScope."""
|
||||||
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal")
|
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal")
|
||||||
|
assert kw["reasoning_effort"] == "minimum"
|
||||||
assert kw["extra_body"] == {"enable_thinking": False}
|
assert kw["extra_body"] == {"enable_thinking": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashscope_thinking_disabled_for_minimum_alias() -> None:
|
||||||
|
"""Native 'minimum' spelling must also disable thinking, not enable it."""
|
||||||
|
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimum")
|
||||||
|
assert kw["reasoning_effort"] == "minimum"
|
||||||
|
assert kw["extra_body"] == {"enable_thinking": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_dashscope_minimal_not_retranslated() -> None:
|
||||||
|
"""DashScope-specific translation must not leak to other providers."""
|
||||||
|
kw = _build_kwargs_for("openai", "gpt-5", reasoning_effort="minimal")
|
||||||
|
assert kw["reasoning_effort"] == "minimal"
|
||||||
|
|
||||||
|
|
||||||
def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None:
|
def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None:
|
||||||
kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None)
|
kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None)
|
||||||
assert "extra_body" not in kw
|
assert "extra_body" not in kw
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Tests for session fsync and flush_all on graceful shutdown."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sessions_dir(tmp_path: Path) -> Path:
|
||||||
|
d = tmp_path / "sessions"
|
||||||
|
d.mkdir()
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(sessions_dir: Path) -> SessionManager:
|
||||||
|
return SessionManager(workspace=sessions_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveFsync:
|
||||||
|
"""Verify that save(fsync=True) calls os.fsync."""
|
||||||
|
|
||||||
|
def test_save_without_fsync_does_not_call_fsync(self, manager: SessionManager):
|
||||||
|
session = manager.get_or_create("test:no-fsync")
|
||||||
|
session.add_message("user", "hello")
|
||||||
|
|
||||||
|
with patch("os.fsync") as mock_fsync:
|
||||||
|
manager.save(session, fsync=False)
|
||||||
|
mock_fsync.assert_not_called()
|
||||||
|
|
||||||
|
def test_save_with_fsync_calls_fsync(self, manager: SessionManager):
|
||||||
|
session = manager.get_or_create("test:with-fsync")
|
||||||
|
session.add_message("user", "hello")
|
||||||
|
|
||||||
|
with patch("os.fsync") as mock_fsync:
|
||||||
|
manager.save(session, fsync=True)
|
||||||
|
# File fsync always runs; directory fsync only on non-Windows.
|
||||||
|
expected = 1 if _IS_WINDOWS else 2
|
||||||
|
assert mock_fsync.call_count == expected
|
||||||
|
|
||||||
|
def test_save_default_no_fsync(self, manager: SessionManager):
|
||||||
|
"""Default save() should not fsync (backward compat)."""
|
||||||
|
session = manager.get_or_create("test:default")
|
||||||
|
session.add_message("user", "hello")
|
||||||
|
|
||||||
|
with patch("os.fsync") as mock_fsync:
|
||||||
|
manager.save(session)
|
||||||
|
mock_fsync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlushAll:
|
||||||
|
"""Verify flush_all re-saves all cached sessions with fsync."""
|
||||||
|
|
||||||
|
def test_flush_all_empty_cache(self, manager: SessionManager):
|
||||||
|
assert manager.flush_all() == 0
|
||||||
|
|
||||||
|
def test_flush_all_saves_cached_sessions(self, manager: SessionManager):
|
||||||
|
s1 = manager.get_or_create("test:session-1")
|
||||||
|
s1.add_message("user", "msg 1")
|
||||||
|
manager.save(s1)
|
||||||
|
|
||||||
|
s2 = manager.get_or_create("test:session-2")
|
||||||
|
s2.add_message("user", "msg 2")
|
||||||
|
manager.save(s2)
|
||||||
|
|
||||||
|
flushed = manager.flush_all()
|
||||||
|
assert flushed == 2
|
||||||
|
|
||||||
|
def test_flush_all_uses_fsync(self, manager: SessionManager):
|
||||||
|
session = manager.get_or_create("test:fsync-check")
|
||||||
|
session.add_message("user", "important")
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
with patch("os.fsync") as mock_fsync:
|
||||||
|
manager.flush_all()
|
||||||
|
# file fsync always; directory fsync only on non-Windows
|
||||||
|
expected = 1 if _IS_WINDOWS else 2
|
||||||
|
assert mock_fsync.call_count == expected
|
||||||
|
|
||||||
|
def test_flush_all_continues_on_error(self, manager: SessionManager):
|
||||||
|
"""One broken session should not prevent others from flushing."""
|
||||||
|
s1 = manager.get_or_create("test:good")
|
||||||
|
s1.add_message("user", "ok")
|
||||||
|
manager.save(s1)
|
||||||
|
|
||||||
|
s2 = manager.get_or_create("test:bad")
|
||||||
|
s2.add_message("user", "ok")
|
||||||
|
manager.save(s2)
|
||||||
|
|
||||||
|
original_save = manager.save
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def patched_save(session, *, fsync=False):
|
||||||
|
call_count["n"] += 1
|
||||||
|
if session.key == "test:bad":
|
||||||
|
raise OSError("disk on fire")
|
||||||
|
original_save(session, fsync=fsync)
|
||||||
|
|
||||||
|
manager.save = patched_save
|
||||||
|
flushed = manager.flush_all()
|
||||||
|
|
||||||
|
# One succeeded, one failed — flush_all returns successful count
|
||||||
|
assert flushed == 1
|
||||||
|
assert call_count["n"] == 2
|
||||||
|
|
||||||
|
def test_flush_all_data_survives_reload(self, sessions_dir: Path):
|
||||||
|
"""Data flushed by flush_all should survive a fresh SessionManager load."""
|
||||||
|
mgr1 = SessionManager(workspace=sessions_dir)
|
||||||
|
session = mgr1.get_or_create("test:persist")
|
||||||
|
session.add_message("user", "remember this")
|
||||||
|
session.add_message("assistant", "noted")
|
||||||
|
mgr1.save(session)
|
||||||
|
mgr1.flush_all()
|
||||||
|
|
||||||
|
# Simulate process restart — new manager, cold cache
|
||||||
|
mgr2 = SessionManager(workspace=sessions_dir)
|
||||||
|
reloaded = mgr2.get_or_create("test:persist")
|
||||||
|
history = reloaded.get_history(max_messages=100)
|
||||||
|
|
||||||
|
assert len(history) == 2
|
||||||
|
assert history[0]["content"] == "remember this"
|
||||||
|
assert history[1]["content"] == "noted"
|
||||||
+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