diff --git a/CLAUDE.md b/CLAUDE.md index 5993fe7..f3e8851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,8 @@ MCP data tools and web search. User-facing docs are in `README.md`; this file is - `make check`: ruff + pytest (no network, a few seconds). Run it before every commit, on Python 3.13.5 (production; `uv venv --python 3.13.5`). GitHub CI runs the latest stable Python and Node.js instead, to catch breakage from the next upgrade early. - `make prompt-report`: token breakdown of a sample request. `python bot.py [label]` runs the bot. +- Pull requests: right after pushing a `claude/*` branch, open its PR if it has none, so Claude Code shows the PR/CI + button. Never merge it unless asked. - Tests use fakes (`tests/conftest.py`: FakeBot, user_msg; `tests/fakes.py`: ScriptedBackend, FakeMcp, fake SDK clients; `tests/fixtures/` real anonymised MCP output). Add a test next to the module you change; don't write scratch scripts. @@ -31,7 +33,7 @@ MCP data tools and web search. User-facing docs are in `README.md`; this file is ## Request flow `handlers.on_message` logs the message -> `_trigger` (mention / reply-to-bot / DM / movers list) -> -`_generate` builds the prompt (`history.format_rows` + `prompts.chat_prompt`) -> `gate.route` picks tools -> +`_generate` builds the prompt (`history.format_rows` + `prompts.chat_prompt`; `#nocontext` in the message keeps only that message) -> `gate.route` picks tools -> `ask.ask` builds a `Request` -> `policy.ask` -> `runner.run` loops `backend.step` / `run_calls` -> `format_answer` (md_to_html, link_tickers) -> `reply_chunks` (split_html, save each sent message). diff --git a/README.md b/README.md index 9b1d57a..30ab56d 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ Restart=on-failure provider's prompt cache keeps working. The bot's own earlier replies appear as "You" (its latest reply in full, so follow-up questions about it work; older ones shortened); a replied-to message is quoted even if it is older than the window. +- **`#nocontext`:** put `#nocontext` anywhere in a message that tags the bot (or in a DM) and the model gets that + message alone: no chat history, and nothing quoted from the message it replies to (no photo from it either). The tag + itself is removed before the model sees it. Useful for a fresh question that earlier chat would only confuse + or make more expensive; the bot's own recent reply is not read either, so follow-ups need the full question. - **Shared database:** group history lives in `messages`, shared by every bot pointed at the same `DB_PATH` (so two bots in one group see each other's replies). Private chats live in `dm_messages`, keyed by bot, because each bot's DM with a person has its own message-ID sequence. @@ -286,6 +290,7 @@ Set `TELEGRAM_BOT_TOKEN` and exactly one of the three LLM keys. | `DATA_DIR` | Directory for the bot's data (the database); a relative `DB_PATH` resolves inside it, an absolute one is used as given. Default: systemd's `STATE_DIRECTORY` (see [Running under systemd](#running-under-systemd)), else the working directory. In Docker, set `DATA_DIR=/data` and bind-mount a host directory there so history survives upgrades. | | `DB_PATH` | SQLite file for chat history (default chat_log.db). Instances may share it. | | `HISTORY_LIMIT` | Messages of chat history in the prompt; the window is HISTORY_LIMIT to 1.5x (default 20). | +| `LOG_WIDTH` | Longest line (characters) for the MCP tool lists in the log; a longer list is cut at a whole name and ends in `...` (default 95, 0 = no limit). | | `MAX_TOKENS` | Reply cap in tokens, reasoning included (default 3000). | | `MCP_CONFIG` | MCP server config file (default mcp_servers.json next to bot.py, whatever `DATA_DIR` is; missing = no MCP tools). | | `MOVERS_BOTS` | Usernames of bots whose end-of-day big-movers lists get explained. Empty (default) = feature off. | diff --git a/lib/app.py b/lib/app.py index 96f977d..bf3310d 100644 --- a/lib/app.py +++ b/lib/app.py @@ -59,9 +59,11 @@ def setup_logging() -> None: def _drop_protocol_line(record: logging.LogRecord) -> bool: - """The MCP library's own "Negotiated protocol version" line doesn't say which server it is about; the - bot logs "MCP : connected, protocol ..." instead.""" - return not record.getMessage().startswith("Negotiated protocol version") + """Library log lines that are noise here: the MCP library's own "Negotiated protocol version" line + doesn't say which server it is about (the bot logs "MCP : connected, protocol ..." instead), and + a remote server's 30 s keepalive arrives as an "Unknown SSE event: ping" warning.""" + msg = record.getMessage() + return not (msg.startswith("Negotiated protocol version") or msg == "Unknown SSE event: ping") async def alert_down(ctx: Ctx, server: MCPServer) -> None: @@ -94,7 +96,8 @@ async def on_down(server: MCPServer) -> None: await alert_down(ctx, server) ctx.registry = Registry.load(st.mcp_config, timeout=config.MCP_TIMEOUT, - max_output=st.max_tool_output, on_down=on_down) + max_output=st.max_tool_output, on_down=on_down, + log_width=st.log_width) return ctx diff --git a/lib/config.py b/lib/config.py index a2e7ff1..a3de261 100644 --- a/lib/config.py +++ b/lib/config.py @@ -29,6 +29,7 @@ "DATA_DIR": "Directory for the bot's data (the database); a relative DB_PATH resolves inside it. Default: systemd's STATE_DIRECTORY, else the working directory.", "DB_PATH": "SQLite file for chat history (default chat_log.db). Instances may share it.", "HISTORY_LIMIT": "Messages of chat history in the prompt; the window is HISTORY_LIMIT to 1.5x (default 20).", + "LOG_WIDTH": "Longest line (characters) for the MCP tool lists in the log; longer lists end in \"...\" (default 95, 0 = no limit).", "MAX_TOKENS": "Reply cap in tokens, reasoning included (default 3000).", "MCP_CONFIG": "MCP server config file (default mcp_servers.json next to bot.py; missing = no MCP tools).", "MOVERS_BOTS": "Usernames of bots whose end-of-day big-movers lists get explained. Empty (default) = feature off.", @@ -75,6 +76,7 @@ class Settings: search_model: str max_tokens: int history_limit: int + log_width: int db_path: str tz: ZoneInfo mcp_config: str @@ -191,6 +193,7 @@ def load(env: Mapping[str, str] | None = None) -> Settings: search_model=search_model, max_tokens=int(env.get("MAX_TOKENS") or 3000), history_limit=int(env.get("HISTORY_LIMIT") or 20), + log_width=int(env.get("LOG_WIDTH") or 95), db_path=data_path(env, "DB_PATH", "chat_log.db"), tz=ZoneInfo(env.get("BOT_TZ", "UTC")), mcp_config=mcp_config_path(env), diff --git a/lib/llm/runner.py b/lib/llm/runner.py index cfefe87..46be9b5 100644 --- a/lib/llm/runner.py +++ b/lib/llm/runner.py @@ -198,8 +198,8 @@ async def run(backend: Backend, registry: Registry, req: Request, *, dedupe: boo break if req.must_search and not (tool_calls or searched): log.warning("This request needed a search, but the answer shows no sign of one") - log.info("%.1fs, %d rounds, %d tools: in %d (%.0f%% cached) out %d, $%.4f", - time.monotonic() - t0, rounds, tool_calls, total.tokens_in, total.cached_pct, + log.info("%.1fs, %d rounds, %d tools, %d searches: in %d (%.0f%% cached) out %d, $%.4f", + time.monotonic() - t0, rounds, tool_calls, searched, total.tokens_in, total.cached_pct, total.tokens_out, total.cost) text = TOOL_SYNTAX_RE.sub("", step.text).strip() if not text and step.finish == "length" and tool_calls: diff --git a/lib/llm/xai.py b/lib/llm/xai.py index 477514d..c78d313 100644 --- a/lib/llm/xai.py +++ b/lib/llm/xai.py @@ -33,19 +33,26 @@ def _as_input(item) -> dict: return item.model_dump(exclude_none=True) if hasattr(item, "model_dump") else dict(item) -def answer_text(resp) -> str: - """The response's last message. `output_text` joins every message, and with server-side search - the model can write a draft, search, then write the answer, all in one response.""" +def answer_text(resp, tail: str | None = None) -> str: + """The response's last message, and in it the last text part. `output_text` joins everything, and + with server-side search the model can write a draft, search, then write the answer, as separate + messages, as parts of one, or as one run of text with the searches in between. `tail` is the text + streamed after the last search call; when the chosen text ends with it, the draft before it goes.""" messages = [i for i in resp.output if i.type == "message"] parts = [[getattr(c, "text", "") or "" for c in m.content if c.type == "output_text"] for m in messages] texts = ["".join(p) for p in parts] - for p in parts: - if len([t for t in p if t.strip()]) > 1: # not handled yet: this line tells us if it happens - log.info("xAI message had %d text parts (%s chars)", len(p), "+".join(str(len(t)) for t in p)) if len([t for t in texts if t.strip()]) > 1: log.info("xAI response had %d messages; kept the last (items: %s)", len(texts), " ".join(i.type for i in resp.output)) - return next((t for t in reversed(texts) if t.strip()), resp.output_text or "").strip() + for p in parts: + if len([t for t in p if t.strip()]) > 1: # not handled yet: this line tells us if it happens + log.info("xAI message had %d text parts (%s chars)", len(p), "+".join(str(len(t)) for t in p)) + text = next((t for t in reversed(texts) if t.strip()), resp.output_text or "").strip() + if tail and tail.strip() and len(tail.strip()) < len(text) and text.endswith(tail.strip()): + log.info("xAI wrote %d chars before a search call; kept the %d after it (items: %s)", + len(text) - len(tail.strip()), len(tail.strip()), " ".join(i.type for i in resp.output)) + return tail.strip() + return text class XaiBackend(Backend): @@ -87,12 +94,17 @@ def _kwargs(self, req: Request, tool_choice: str | None) -> dict: async def _create(self, req: Request, kw: dict, **inputs): stream = await self.client.responses.create(**kw, **inputs) - text, final = "", None + text, final, tail = "", None, None async for event in stream: if event.type == "response.output_item.added" and event.item.type == "message": text = "" # a new message replaces the draft before it + elif event.type == "response.output_item.added" and event.item.type.endswith("_search_call"): + if text.strip(): # text written before a search is a draft; only what follows counts + text, tail = "", "" elif event.type == "response.output_text.delta": text += event.delta + if tail is not None: + tail = text if req.on_text: req.on_text(text) elif event.type in ("response.completed", "response.incomplete", "response.failed"): @@ -102,23 +114,23 @@ async def _create(self, req: Request, kw: dict, **inputs): if final.status == "failed": msg = f"xAI response failed: {final.error}" raise (Overloaded if getattr(final.error, "code", None) == "server_error" else RuntimeError)(msg) - return final + return final, tail async def step(self, conv: dict, req: Request, *, tool_choice: str | None) -> Step: kw = self._kwargs(req, tool_choice) prev = conv["resp"] try: if self.stateless or prev is None: - resp = await self._create(req, kw, input=conv["input"]) + resp, tail = await self._create(req, kw, input=conv["input"]) else: - resp = await self._create(req, kw, previous_response_id=prev.id, input=conv["pending"]) + resp, tail = await self._create(req, kw, previous_response_id=prev.id, input=conv["pending"]) except BadRequestError as e: if prev is None or not self.stateless: raise log.warning("xAI rejected the replayed conversation (%s); using previous_response_id " "from now on", e) self.stateless = False - resp = await self._create(req, kw, previous_response_id=prev.id, input=conv["last_results"]) + resp, tail = await self._create(req, kw, previous_response_id=prev.id, input=conv["last_results"]) conv["resp"] = resp calls = [Call(i.call_id, i.name, i.arguments) for i in resp.output if i.type == "function_call"] u = resp.usage @@ -130,7 +142,7 @@ async def step(self, conv: dict, req: Request, *, tool_choice: str | None) -> St cost=(getattr(u, "cost_in_usd_ticks", 0) or 0) / TICKS_PER_USD, ) searches = sum(1 for i in resp.output if i.type.endswith("_search_call")) - return Step(answer_text(resp), calls, usage, resp.status, searches) + return Step(answer_text(resp, tail), calls, usage, resp.status, searches) def add_results(self, conv: dict, step: Step, results: list[str]) -> None: items = [{"type": "function_call_output", "call_id": c.id, "output": out} diff --git a/lib/mcp/server.py b/lib/mcp/server.py index 871cc9b..260cc79 100644 --- a/lib/mcp/server.py +++ b/lib/mcp/server.py @@ -9,7 +9,6 @@ import logging import re import tempfile -import textwrap import time from collections.abc import Awaitable, Callable from contextlib import AsyncExitStack @@ -44,11 +43,11 @@ class MCPServer: entered and exited in the same task.""" def __init__(self, label: str, cfg: dict, *, timeout: float, max_output: int, - on_down: Callable[[MCPServer], Awaitable[None]] | None = None): + on_down: Callable[[MCPServer], Awaitable[None]] | None = None, log_width: int = 95): self.label = re.sub(r"[^A-Za-z0-9_-]", "_", label) self.cfg = cfg self.description = cfg.get("description", "") - self.timeout, self.max_output, self.on_down = timeout, max_output, on_down + self.timeout, self.max_output, self.on_down, self.log_width = timeout, max_output, on_down, log_width self.session: ClientSession | None = None self.error: str | None = None # why the server is down, if it is self.tools: list[ToolDef] = [] @@ -148,9 +147,9 @@ async def _load_tools(self, session: ClientSession) -> None: self._warn_unknown_names(listed, allow | blocked | set(self._warm)) enabled = sorted(self.fn_names.values()) skipped = sorted(t.name for t in listed if t.name not in enabled) - log_names(f"MCP {self.label}: {len(enabled)} tools:", enabled) + log_names(f"MCP {self.label}: {len(enabled)} tools:", enabled, self.log_width) if skipped: - log_names(f"MCP {self.label}: {len(skipped)} skipped:", skipped) + log_names(f"MCP {self.label}: {len(skipped)} skipped:", skipped, self.log_width) def _warn_unknown_names(self, listed, configured: set[str]) -> None: """A blocked/allowed name that matches no tool does nothing; say so (and what was meant).""" @@ -290,7 +289,7 @@ def __init__(self, servers: list[MCPServer]): @classmethod def load(cls, path: str, *, timeout: float, max_output: int, - on_down: Callable[[MCPServer], Awaitable[None]] | None = None) -> Registry: + on_down: Callable[[MCPServer], Awaitable[None]] | None = None, log_width: int = 95) -> Registry: """Servers from a JSON file ({"mcpServers": {...}} or Claude Desktop style); a missing file means no servers.""" try: @@ -299,7 +298,8 @@ def load(cls, path: str, *, timeout: float, max_output: int, except FileNotFoundError: return cls([]) cfg = cfg.get("mcpServers", cfg) - return cls([MCPServer(label, c, timeout=timeout, max_output=max_output, on_down=on_down) + return cls([MCPServer(label, c, timeout=timeout, max_output=max_output, on_down=on_down, + log_width=log_width) for label, c in cfg.items() if not c.get("disabled")]) def start(self) -> None: @@ -330,11 +330,18 @@ def lookup(self, fn_name: str) -> tuple[MCPServer, str] | None: return None -def log_names(head: str, names: list[str], width: int = 80) -> None: - """Log a list of tool names on lines short enough not to wrap in a terminal or journal.""" - lines = textwrap.wrap(", ".join(names), width - len(head)) - for i, line in enumerate(lines): - log.info("%s %s", head if i == 0 else " " * len(head), line) +def log_names(head: str, names: list[str], width: int = 95) -> None: + """Log a list of tool names on one line of at most `width` characters (0 = no limit). A list that + doesn't fit is cut at a whole name and ends in "...".""" + line = f"{head} {', '.join(names)}" + if width and len(line) > width: + kept = f"{head} " + for i, name in enumerate(names): + if len(kept) + len(name) + len(", ...") > width and i: + break + kept += f"{name}, " + line = kept + "..." + log.info("%s", line) def _leaf_errors(e: BaseException) -> list[BaseException]: diff --git a/lib/prompts.py b/lib/prompts.py index d9cb3da..b4dc0f9 100644 --- a/lib/prompts.py +++ b/lib/prompts.py @@ -72,8 +72,11 @@ def chat_prompt(*, transcript: str, sender: str, private: bool, reply_quote: str fresh: bool = False) -> str: """The user turn: transcript, the replied-to message, and the volatile tail (kept last so the cached prefix before it is unchanged).""" - head = (f"Private one-to-one chat with {sender}, not the group. Transcript:\n{transcript}" - if private else f"Group chat transcript:\n{transcript}") + if transcript: + head = (f"Private one-to-one chat with {sender}, not the group. Transcript:\n{transcript}" + if private else f"Group chat transcript:\n{transcript}") + else: # a standalone request (a movers list): what follows is all there is + head = f"Private one-to-one chat with {sender}, not the group." if private else "Group chat." if reply_quote: head += f"\n\nThe last message (#{msg_id}) is a reply to this message:\n{reply_quote}" head += middle diff --git a/lib/telegram/handlers.py b/lib/telegram/handlers.py index b4f669e..583db15 100644 --- a/lib/telegram/handlers.py +++ b/lib/telegram/handlers.py @@ -33,6 +33,7 @@ FOLLOW_UP_WORDS = 12 # a message this short right after the bot's reply may be a follow-up to it FOLLOW_UP_SECONDS = 30 * 60 FOLLOW_UP_CHARS = 400 # of the bot's reply that the tool gate also reads +NO_CONTEXT = re.compile(r"[ \t]*(? Trigger | None: and reply_target.from_user.id == bot.id) if not (is_movers or private or mentions(text, bot.username) or replied_to_bot): return None - return Trigger(text, private, is_movers, reply_target, replied_to_bot) + nocontext = bool(NO_CONTEXT.search(text)) + if nocontext: # the model never sees the tag, or what the message replies to + text, reply_target = NO_CONTEXT.sub("", text).strip(), None + return Trigger(text, private, is_movers, reply_target, replied_to_bot, nocontext) async def _command(self, msg: Message, trig: Trigger) -> bool: """Private-chat commands handled here (/credits has its own handler).""" @@ -171,7 +176,7 @@ async def _gate_text(self, msg: Message, trig: Trigger) -> str: terms?") sent right after the bot's own reply also gets the start of that reply, so it counts as part of the same (portfolio, market...) conversation instead of reaching the model with no tools.""" text = trig.text + " " + (describe(trig.reply_target) if trig.reply_target else "") - if trig.reply_target is None and len(trig.text.split()) <= FOLLOW_UP_WORDS: + if trig.reply_target is None and not trig.nocontext and len(trig.text.split()) <= FOLLOW_UP_WORDS: rows = await asyncio.to_thread(self.ctx.store.history, msg.chat_id, 2) if len(rows) >= 2 and rows[-1].message_id == msg.message_id: prev = rows[-2] @@ -179,9 +184,13 @@ async def _gate_text(self, msg: Message, trig: Trigger) -> str: text += " " + compact(prev.text)[:FOLLOW_UP_CHARS] return text - async def _chat_text(self, msg: Message, trig: Trigger, middle: str = "", fresh: bool = False) -> str: + async def _chat_text(self, msg: Message, trig: Trigger, middle: str = "", fresh: bool = False, + no_history: bool = False) -> str: ctx, st = self.ctx, self.ctx.st - rows = await asyncio.to_thread(ctx.store.history, msg.chat_id, st.history_limit) + rows = [] if no_history else await asyncio.to_thread(ctx.store.history, msg.chat_id, st.history_limit) + if trig.nocontext: # only the message that tagged the bot + rows = [r._replace(text=NO_CONTEXT.sub("", r.text).strip(), reply_to=None) + for r in rows if r.message_id == msg.message_id] transcript = format_rows(rows, ctx.self_name, st.tz, line_max=st.history_line_max, compact_text=st.token_saver, own_line_max=st.history_own_line_max) return chat_prompt( @@ -237,7 +246,7 @@ async def _generate(self, msg: Message, trig: Trigger, draft: Draft | None) -> s kind="preset", chat_id=chat_id) elif trig.movers: listing = msg.text_html if msg.text else (msg.caption_html or "") - text = await self._chat_text(msg, trig, MOVERS_EXPLAIN.format(listing=listing)) + text = await self._chat_text(msg, trig, MOVERS_EXPLAIN.format(listing=listing), no_history=True) parts = [{"type": "text", "text": text}] answer = await ask(ctx, parts, route("", st, ctx.registry, force_full=True), must_search=True, cache_key=f"chat-{chat_id}", on_text=on_text, diff --git a/tests/test_app.py b/tests/test_app.py index f8f84e7..3ea0c17 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -181,7 +181,7 @@ async def fake_loop(ctx): assert len(started) == expected -def test_the_mcp_librarys_unlabelled_protocol_line_is_dropped(monkeypatch): +def test_the_mcp_librarys_unlabelled_protocol_and_ping_lines_are_dropped(monkeypatch): import logging monkeypatch.delenv("JOURNAL_STREAM", raising=False) @@ -193,6 +193,8 @@ def test_the_mcp_librarys_unlabelled_protocol_line_is_dropped(monkeypatch): keep = [h for h in root.handlers] mk = lambda msg: logging.LogRecord("mcp.client", logging.INFO, "f", 1, msg, None, None) # noqa: E731 assert all(not h.filter(mk("Negotiated protocol version: 2025-11-25")) for h in keep) + assert all(not h.filter(mk("Unknown SSE event: ping")) for h in keep) + assert all(h.filter(mk("Unknown SSE event: other")) for h in keep) # other unknown events still show assert all(h.filter(mk("MCP yahoo: connected, protocol 2025-11-25")) for h in keep) finally: root.handlers[:] = saved[0] diff --git a/tests/test_config.py b/tests/test_config.py index d8cc75e..5ddfc3a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -74,6 +74,13 @@ def test_holding_news_and_movers_switch_on_by_listing_names(env): assert not config.load({**env, "MOVERS_BOTS": ","}).movers_explain +def test_log_width_defaults_to_95(env): + assert config.load(env).log_width == 95 + assert config.load({**env, "LOG_WIDTH": "120"}).log_width == 120 + assert config.load({**env, "LOG_WIDTH": "0"}).log_width == 0 # 0 = no limit + assert config.load({**env, "LOG_WIDTH": ""}).log_width == 95 + + def test_token_saver_switches_defaults(env): on, off = config.load(env), config.load({**env, "TOKEN_SAVER": "off"}) assert (on.max_tokens, off.max_tokens) == (3000, 3000) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ba7f5a5..3247b60 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -188,6 +188,35 @@ async def test_busy_provider_gets_a_plain_reply_and_a_one_line_log(env, store, c assert "\n" not in line and not any(r.exc_info for r in caplog.records) +async def test_nocontext_sends_only_the_tagging_message_without_the_tag(env, store): + ctx, backend, h = make_ctx(env, store, [step("fine"), step("fine too")]) + await run(h, user_msg(ctx.bot, "did anyone watch the football last night", mid=1)) + target = user_msg(ctx.bot, "BHP looks cheap", mid=2) + await run(h, target) + m = user_msg(ctx.bot, "@stockbot #NoContext what do you make of NVDA?", mid=3) + m.reply_to_message = target + await run(h, m) + prompt = backend.seen[0]["prompt"] + assert "NVDA" in prompt and "nocontext" not in prompt.lower() + assert "football" not in prompt and "BHP" not in prompt # no history, no replied-to message + assert "is a reply to" not in prompt + assert len(m.replies) == 1 + await run(h, user_msg(ctx.bot, "@stockbot what about NVDA?", mid=4)) # without the tag: history is back + assert "football" in backend.seen[1]["prompt"] + + +async def test_movers_prompt_leaves_out_the_earlier_chat(env, store): + ctx, backend, h = make_ctx(env, store, [step("NVDA up on news.")], MOVERS_BOTS="finbotibot") + await run(h, user_msg(ctx.bot, "did anyone watch the football last night")) + m = user_msg(ctx.bot, "≥ 5.0% at close (ASX):\nNVDA +6.1%\nAMD +5.5%", is_bot=True, mid=2) + m.from_user.is_bot, m.from_user.username = True, "finbotibot" + m.parse_entities = lambda types=None: {"b": "≥ 5.0% at close (ASX):"} + await run(h, m) + prompt = backend.seen[0]["prompt"] + assert "football" not in prompt and "explain" in prompt + assert prompt.count("NVDA +6.1%") == 1 # the list once, not also as the last transcript line + + async def test_movers_list_off_by_default_then_answered_once_without_tagging(env, store): text = "≥ 5.0% at close (ASX):\nNVDA +6.1%\nAMD +5.5%" for flag, expect in (({}, 0), ({"MOVERS_BOTS": "finbotibot"}, 1)): diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index f205dd5..d9662da 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -258,6 +258,38 @@ async def test_xai_logs_a_message_with_several_text_parts_and_still_joins_them(c "xAI message had 2 text parts (3+3 chars)"] +async def test_xai_drops_text_written_before_a_search_inside_one_message(caplog): + from types import SimpleNamespace as N + caplog.set_level("INFO", logger="bot") + st = config.load({"TELEGRAM_BOT_TOKEN": "1:x", "XAI_API_KEY": "k"}) + message = xai_message("generic draft.answer from the news") + items = [N(type="web_search_call"), message] + events = [N(type="response.output_item.added", item=message), + N(type="response.output_text.delta", delta="generic draft."), + N(type="response.output_item.added", item=items[0]), + N(type="response.output_text.delta", delta="answer from the news"), + xai_final(items, text="generic draft.answer from the news")] + seen = [] + b = XaiBackend(st, FakeXaiClient(events)) + r = req(on_text=seen.append) + step = await b.step(b.start(r), r, tool_choice=None) + assert step.text == "answer from the news" and step.searches == 1 + assert seen == ["generic draft.", "answer from the news"] + assert any("wrote 14 chars before a search call" in m.getMessage() for m in caplog.records) + + +async def test_xai_leaves_text_alone_when_no_search_interrupts_it(): + from types import SimpleNamespace as N + st = config.load({"TELEGRAM_BOT_TOKEN": "1:x", "XAI_API_KEY": "k"}) + message = xai_message("one answer") + events = [N(type="response.output_item.added", item=message), + N(type="response.output_text.delta", delta="one answer"), + xai_final([message], text="one answer")] + b = XaiBackend(st, FakeXaiClient(events)) + r = req() + assert (await b.step(b.start(r), r, tool_choice=None)).text == "one answer" + + async def test_xai_failed_response_at_capacity_is_overloaded_other_failures_are_not(): from types import SimpleNamespace as N st = config.load({"TELEGRAM_BOT_TOKEN": "1:x", "XAI_API_KEY": "k"}) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 24cc860..81a802a 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -105,6 +105,24 @@ def test_log_names_keeps_exact_names(caplog): assert "get_analyst_estimates" in caplog.text and "get_stock_quote" in caplog.text +def test_log_names_is_one_line_cut_at_a_whole_name(caplog): + import logging + + from lib.mcp.server import log_names + names = ["compare_companies", "get_company", "get_company_metrics", "get_data_notes", "get_events"] + head = "MCP t: 5 tools:" + with caplog.at_level(logging.INFO, logger="bot"): + log_names(head, names, 95) + log_names(head, names, 60) + log_names(head, names, 0) + log_names(head, names, 5) + short, cut, unlimited, tiny = (r.getMessage() for r in caplog.records) + assert short == f"{head} {', '.join(names)}" and len(short) <= 95 + assert cut == f"{head} compare_companies, get_company, ..." and len(cut) <= 60 + assert unlimited == short + assert tiny == f"{head} compare_companies, ..." # the first name is always shown + + async def test_call_slims_and_caches_identical_concurrent_calls(): s = make_server(cache_ttl=30) a, b = await asyncio.gather(s.call("get_a", {"t": "X"}), s.call("get_a", {"t": "X"})) diff --git a/tests/test_runner.py b/tests/test_runner.py index 17b3d38..ec3b3e9 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -133,6 +133,12 @@ async def test_forced_search_first_round_and_retry_without_required_on_400(): await run(b2, registry(), req(must_search=True)) +async def test_summary_line_counts_the_providers_own_searches(caplog): + caplog.set_level("INFO", logger="bot") + await run(ScriptedBackend([step("done", searches=3)]), registry(), req(must_search=True)) + assert any("0 tools, 3 searches" in r.getMessage() for r in caplog.records) + + async def test_fake_search_call_text_is_recovered_once_and_failures_reported(): async def search(q): raise RuntimeError("rate limited")