From f205ad8d90cc7236f6fe2d5d493882762018131d Mon Sep 17 00:00:00 2001 From: Nick Bowers Date: Thu, 3 Sep 2026 13:40:09 +0000 Subject: [PATCH] fix(channels): gate /compact on backend capability (#8156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backend outside ACP_BACKENDS_COMPACT (KAS) treats the /compact prompt as ordinary text and never answers, so the messaging surfaces' ungated provider.compact() dispatches stranded the status wait for the call-site bound (120s on Slack/Telegram/Discord, _COMPACT_TIMEOUT_S on Webex) or indefinitely (Teams, Feishu, iMessage, WeCom, Weixin, WhatsApp) while holding the turn semaphore, then reported an error for a backend that was never going to answer. Mirror the dashboard's manual-/compact capability gate (#7800) at every transport call site through one shared decision helper, messaging.commands.compact_unsupported_backend (acts only on a non-empty str, the ABC's stated contract, so mocked providers never read as a refusal), plus compact_unsupported_reply for the informational wording. Ten surfaces are gated: Slack, Telegram, Discord, Webex, Teams, Feishu, iMessage, WeCom, Weixin and WhatsApp — the issue's list of eight missed Feishu and iMessage, which carry the same shape with an unbounded wait (caught in pre-push review). Manual /compact commands answer immediately and informationally, with the declined backend id logged; the context-threshold notices decline silently — no forced hard-threshold compaction to run, and no soft nudge whose /compact advice the backend would refuse. The Chinese-language surfaces translate the reply; iMessage and WhatsApp keep their plain-text voice. Supported backends keep the exact current behavior. Red-before-green tests per transport pin that an unsupported backend gets the informational reply with compact() never dispatched (semaphore still released), that a None/absent capability preserves the existing dispatch, and that both threshold notices stay silent; providers.md now records the messaging surfaces as gated. Closes #8156 Co-authored-by: Kiro Crew --- docs/system-specs/modules/providers.md | 2 +- src/kiro_crew/discord/transport_dispatch.py | 20 ++++++- src/kiro_crew/feishu/transport_dispatch.py | 22 +++++++ src/kiro_crew/imessage/transport_dispatch.py | 23 +++++++ src/kiro_crew/messaging/commands.py | 33 ++++++++++ src/kiro_crew/slack/handler.py | 19 ++++++ src/kiro_crew/teams/transport_dispatch.py | 18 ++++++ src/kiro_crew/telegram/transport_dispatch.py | 16 +++++ src/kiro_crew/webex/transport_dispatch.py | 17 ++++++ src/kiro_crew/wecom/transport_dispatch.py | 20 +++++++ src/kiro_crew/weixin/transport_dispatch.py | 21 +++++++ src/kiro_crew/whatsapp/commands.py | 8 +++ src/kiro_crew/whatsapp/transport_dispatch.py | 22 +++++++ test/test_discord.py | 38 ++++++++++++ test/test_feishu_dispatch.py | 58 ++++++++++++++++++ test/test_imessage_dispatch.py | 42 +++++++++++++ test/test_messaging_commands.py | 34 +++++++++++ test/test_slack_handler.py | 36 +++++++++++ test/test_teams_dispatch.py | 63 ++++++++++++++++++++ test/test_telegram.py | 52 ++++++++++++++++ test/test_webex_dispatch.py | 63 ++++++++++++++++++++ test/test_wecom_dispatch.py | 63 ++++++++++++++++++++ test/test_weixin_dispatch.py | 44 ++++++++++++++ test/test_whatsapp_dispatch.py | 44 ++++++++++++++ 24 files changed, 776 insertions(+), 2 deletions(-) diff --git a/docs/system-specs/modules/providers.md b/docs/system-specs/modules/providers.md index 4d12a583b02..084af1089e1 100644 --- a/docs/system-specs/modules/providers.md +++ b/docs/system-specs/modules/providers.md @@ -91,7 +91,7 @@ glue or a provider selector (see the repo-root `CLAUDE.md`). - `approve_tool()`/`reject_tool()` → JSON-RPC response - `context_usage_pct()` → reads `last_prompt_stats.context_pct` - `context_window_tokens()` → reads `last_prompt_stats.context_window_tokens` (the real served window from `usage_update.size`, 0 if unknown). Used by the dashboard token text instead of re-deriving the window from the model id. A mid-session `set_model` (live switch on both `AcpClient` and `AcpSessionHandle`) rebases these stats via `AcpPromptStats.rebase_to_window`: the window is re-derived from `model_registry.model_window` (0 on a registry miss), `context_used_tokens` is kept, `context_pct` is recomputed and clamped, and `context_tokens_from_usage` is cleared so the next metadata `contextUsagePercentage` can backfill against the NEW model instead of being gated forever by the old model's `usage_update`. The dashboard model-switch endpoint then broadcasts one `context_usage` WS event with `reset: true` (both live-switch and session-reset paths, single and bulk), which lets the frontend reducer replace or delete its stored per-slot token counts — per-turn events without `reset` never delete. The post-compaction pct-0 broadcast carries the same flag. -- `compact()` → sends `/compact` via `send_command()`. The **dashboard's** manual `/compact` gates on `ACP_BACKENDS_COMPACT` first, as a pre-acquisition local command: the live session's `manual_compact_unsupported_backend` capability property (declared on the `LLMProvider` ABC with a `None` (supported) default per harness-parity H14, answered by the ACP implementations from set membership) is peeked when a session exists, else the same `agent.acp_backend` config the factory would build one with — so a refused `/compact` behaves as if the turn never started (no session created, no Slack OPTIONS expired, no one-shot turn state consumed). The reply is informational — the backend manages compaction automatically, mirroring the `cc_managed` relationship — not an error: kiro-cli answers the prompt with `_kiro.dev/compaction/status` and claude-agent-acp compacts natively in-prompt, but KAS treats the prompt as ordinary text and never emits a status, so an ungated manual `/compact` would strand `wait_for_compaction()` for the full `COMPACT_WAIT_TIMEOUT_SECS` (#7800). The **auto-compact** path consults the same capability from the compaction gate ladder (`session_compaction._compact_unsupported_backend`) and declines with `"compact_unsupported"` before the compaction task is scheduled, so no `/compact` is dispatched and the turn semaphore is never acquired — an ungated dispatch stranded the status wait for the whole `COMPACT_WAIT_TIMEOUT_SECS` while HOLDING that semaphore and then recycled the session (#7812). The messaging-surface `/compact` commands are still **not yet gated**: Slack, Telegram, Discord, Webex, Teams, WeCom, Weixin and WhatsApp all call `provider.compact()` with no capability check — tracked in #8156. The gate covers only the user-typed command — KAS auto-summarization frames keep mapping to compaction status. +- `compact()` → sends `/compact` via `send_command()`. The **dashboard's** manual `/compact` gates on `ACP_BACKENDS_COMPACT` first, as a pre-acquisition local command: the live session's `manual_compact_unsupported_backend` capability property (declared on the `LLMProvider` ABC with a `None` (supported) default per harness-parity H14, answered by the ACP implementations from set membership) is peeked when a session exists, else the same `agent.acp_backend` config the factory would build one with — so a refused `/compact` behaves as if the turn never started (no session created, no Slack OPTIONS expired, no one-shot turn state consumed). The reply is informational — the backend manages compaction automatically, mirroring the `cc_managed` relationship — not an error: kiro-cli answers the prompt with `_kiro.dev/compaction/status` and claude-agent-acp compacts natively in-prompt, but KAS treats the prompt as ordinary text and never emits a status, so an ungated manual `/compact` would strand `wait_for_compaction()` for the full `COMPACT_WAIT_TIMEOUT_SECS` (#7800). The **auto-compact** path consults the same capability from the compaction gate ladder (`session_compaction._compact_unsupported_backend`) and declines with `"compact_unsupported"` before the compaction task is scheduled, so no `/compact` is dispatched and the turn semaphore is never acquired — an ungated dispatch stranded the status wait for the whole `COMPACT_WAIT_TIMEOUT_SECS` while HOLDING that semaphore and then recycled the session (#7812). The **messaging-surface** `/compact` commands (Slack, Telegram, Discord, Webex, Teams, Feishu, iMessage, WeCom, Weixin and WhatsApp) gate on the same capability through `messaging.commands.compact_unsupported_backend` before dispatching, answering with `compact_unsupported_reply` (translated on the Chinese-language surfaces, plain-voiced on iMessage and WhatsApp); their context-threshold notices decline silently on such a backend — no forced hard-threshold compaction to run, and no soft nudge whose `/compact` advice cannot work (#8156). Gating covers only command dispatch — KAS auto-summarization frames keep mapping to compaction status. - `cancel()` → sends `session/cancel` notification - `supports_effort()` / `change_effort(level)` / `clear_effort()` → reasoning-effort control (see below) - `is_alive()` → `AcpClient.is_responsive()` (600s stale threshold) diff --git a/src/kiro_crew/discord/transport_dispatch.py b/src/kiro_crew/discord/transport_dispatch.py index 1de84479888..6787889b167 100644 --- a/src/kiro_crew/discord/transport_dispatch.py +++ b/src/kiro_crew/discord/transport_dispatch.py @@ -61,7 +61,11 @@ from kiro_crew.hooks import TOOL_AUTO_APPROVE, TOOL_DENY from kiro_crew.messaging.attachments import IngestLimits from kiro_crew.messaging.attachments import cleanup as cleanup_attachments -from kiro_crew.messaging.commands import stop_running_turn +from kiro_crew.messaging.commands import ( + compact_unsupported_backend, + compact_unsupported_reply, + stop_running_turn, +) from kiro_crew.messaging.dispatch import ( build_auto_approve, build_directive_consumer, @@ -1646,6 +1650,11 @@ async def _maybe_notice( """ pct = self.sessions.check_context_usage(session_key, provider) soft_pct = self.cfg.discord.soft_threshold_pct + if pct >= soft_pct and compact_unsupported_backend(provider): + # Capability gate (#8156): the nudge advises !compact, which this + # backend refuses — it compacts on its own as context fills, so + # there is nothing for the user to act on. + return if pct >= soft_pct and not self._conv.is_awaiting(scope_id): self._conv.set_awaiting(scope_id) assert self.client is not None @@ -1680,6 +1689,15 @@ async def _handle_compact( await self.client.send_message(channel_id, "No active session to compact.") return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # 120s wait below. Informational, never an error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + await self.client.send_message(channel_id, compact_unsupported_reply(unsupported)) + return + status_id = await self.client.send_message(channel_id, "🔄 Compacting context…") result_text: str | None = None diff --git a/src/kiro_crew/feishu/transport_dispatch.py b/src/kiro_crew/feishu/transport_dispatch.py index fa5fd0893a4..6452f784d88 100644 --- a/src/kiro_crew/feishu/transport_dispatch.py +++ b/src/kiro_crew/feishu/transport_dispatch.py @@ -30,6 +30,7 @@ from kiro_crew.feishu.renderer import FeishuRenderer from kiro_crew.feishu.transport import FEISHU_CAPABILITIES from kiro_crew.history import mint_row_mid +from kiro_crew.messaging.commands import compact_unsupported_backend from kiro_crew.messaging.conversation import ConversationState from kiro_crew.messaging.dispatch import ( ChannelTurn, @@ -264,6 +265,19 @@ async def _handle_compact(self, inbound: "LarkInbound") -> None: if provider is None: await self.client.send_reply(inbound.message_id, "ℹ️ 当前没有可压缩的对话。") return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # unbounded wait below. Informational (this surface speaks Chinese; + # the wording translates ``compact_unsupported_reply``), never an + # error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("Feishu: manual /compact declined — %s compacts itself", unsupported) + await self.client.send_reply( + inbound.message_id, "ℹ️ 当前后端会自动压缩上下文,无需手动 /compact。" + ) + return await provider.compact() await provider.wait_for_compaction() await self.client.send_reply(inbound.message_id, "🗜️ 已压缩上下文。") @@ -358,6 +372,14 @@ async def _maybe_notice(self, inbound: "LarkInbound", session_key: str, provider assert self.client is not None route = self._route(inbound) pct = self.sessions.check_context_usage(session_key, provider) + if pct >= self.cfg.feishu.soft_threshold_pct: + # Capability gate (#8156): no forced compaction to run and the + # soft nudge's /compact advice cannot work — the backend compacts + # on its own as context fills. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("Feishu: context notice skipped — %s compacts itself", unsupported) + return if pct >= self.cfg.feishu.hard_threshold_pct: self._conv.clear_awaiting(route) try: diff --git a/src/kiro_crew/imessage/transport_dispatch.py b/src/kiro_crew/imessage/transport_dispatch.py index ee3fd4f98fc..56fd77c1756 100644 --- a/src/kiro_crew/imessage/transport_dispatch.py +++ b/src/kiro_crew/imessage/transport_dispatch.py @@ -33,6 +33,7 @@ from kiro_crew.imessage.renderer import IMessageRenderer from kiro_crew.imessage.rpc import RpcError, RpcTransportError from kiro_crew.imessage.transport import IMESSAGE_CAPABILITIES +from kiro_crew.messaging.commands import compact_unsupported_backend from kiro_crew.messaging.dispatch import ( ChannelTurn, build_directive_consumer, @@ -310,6 +311,14 @@ async def _maybe_notice( assert self.client is not None handle = inbound.handle pct = self.sessions.check_context_usage(session_key, provider) + if pct >= self.cfg.imessage.soft_threshold_pct: + # Capability gate (#8156): no forced compaction to run and the + # soft nudge's /compact advice cannot work — the backend compacts + # on its own as context fills. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("imessage: context notice skipped — %s compacts itself", unsupported) + return if pct >= self.cfg.imessage.hard_threshold_pct: self._conv.clear_awaiting(handle) try: @@ -352,6 +361,20 @@ async def _handle_compact(self, inbound: "IMessageInbound") -> None: if provider is None: await self._notify(handle, "ℹ️ There's no conversation to compact yet.") return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # unbounded wait below. Informational, never an error — and plain + # text, because iMessage speech carries no markdown. + unsupported = compact_unsupported_backend(provider) + if unsupported: + await self._notify( + handle, + "ℹ️ This backend manages compaction automatically — it " + "summarizes the conversation on its own as context fills, " + "so manual /compact isn't needed (and isn't supported) here.", + ) + return await provider.compact() await provider.wait_for_compaction() await self._notify(handle, "🗜️ Context compacted.") diff --git a/src/kiro_crew/messaging/commands.py b/src/kiro_crew/messaging/commands.py index 9a52c0c09d7..49cab410cce 100644 --- a/src/kiro_crew/messaging/commands.py +++ b/src/kiro_crew/messaging/commands.py @@ -318,6 +318,39 @@ def format_ttl(ttl_secs: int) -> str: return f"{mins}m" +def compact_unsupported_backend(provider: Any) -> str | None: + """Backend id when *provider* cannot serve a manual ``/compact``, else ``None``. + + The channel half of the dashboard's manual-``/compact`` capability gate + (#7800): a backend outside ``ACP_BACKENDS_COMPACT`` treats the ``/compact`` + prompt as ordinary text and never emits a compaction status, so dispatching + it strands ``wait_for_compaction()`` for its whole deadline. The capability + is read off the LIVE provider — ``manual_compact_unsupported_backend`` is + declared on the ``LLMProvider`` ABC with a ``None`` (supported) default per + harness-parity H14 — and only a non-empty ``str`` (the ABC's stated + contract) reads as a refusal, so a mocked or duck-typed provider's truthy + attribute never blocks a compaction. + """ + value = getattr(provider, "manual_compact_unsupported_backend", None) + if isinstance(value, str) and value: + return value + return None + + +def compact_unsupported_reply(backend: str) -> str: + """Informational reply for a manual ``/compact`` on an unsupported *backend*. + + Mirrors the dashboard's wording: the backend manages compaction + automatically (the same relationship the ``cc_managed`` decline encodes), + so the refusal is information, never an error. + """ + return ( + f"ℹ️ The `{backend}` backend manages compaction automatically — it " + "summarizes the conversation on its own as context fills, so manual " + "`/compact` isn't needed (and isn't supported) here." + ) + + #: How much of a cron job's message body a list row shows. _CRON_MESSAGE_PREVIEW_CHARS = 50 #: How much of a subagent's task a list row shows. diff --git a/src/kiro_crew/slack/handler.py b/src/kiro_crew/slack/handler.py index 6def720deb7..7102b091f17 100644 --- a/src/kiro_crew/slack/handler.py +++ b/src/kiro_crew/slack/handler.py @@ -81,6 +81,8 @@ ) from kiro_crew.messaging import auto_title, privacy_mode from kiro_crew.messaging.commands import ( + compact_unsupported_backend, + compact_unsupported_reply, cron_command_reply, spawn_command_reply, task_command_reply, @@ -2223,6 +2225,23 @@ async def _handle_compact_command( ) return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # 120s wait below. Informational, never an error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + await slack.post_message(channel, compact_unsupported_reply(unsupported), reply_ts) + sel().log_tool_invocation( + session_key=session_key, + source="slack", + tool_name="compact", + tool_kind="command", + outcome="auto_managed_backend", + metadata={"backend": unsupported}, + ) + return + _t0 = time.monotonic() # --- Phase 1: Pre-compaction UI (cosmetic — log failures, don't abort) --- diff --git a/src/kiro_crew/teams/transport_dispatch.py b/src/kiro_crew/teams/transport_dispatch.py index 5ce2187f2d9..0d8b1e8dc43 100644 --- a/src/kiro_crew/teams/transport_dispatch.py +++ b/src/kiro_crew/teams/transport_dispatch.py @@ -41,6 +41,8 @@ from kiro_crew.messaging.attachments import cleanup as cleanup_attachments from kiro_crew.messaging.commands import ( YOLO_PHRASING_MARKDOWN, + compact_unsupported_backend, + compact_unsupported_reply, format_ttl, parse_dashboard_ttl, run_yolo_command, @@ -1066,6 +1068,14 @@ async def _maybe_notice(self, inbound: "TeamsInbound", session_key: str, provide assert self.client is not None email = self._identity(inbound) pct = self.sessions.check_context_usage(session_key, provider) + if pct >= self.cfg.teams.soft_threshold_pct: + # Capability gate (#8156): no forced compaction to run and the + # soft nudge's /compact advice cannot work — the backend compacts + # on its own as context fills. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("Teams: context notice skipped — %s compacts itself", unsupported) + return if pct >= self.cfg.teams.hard_threshold_pct: self._conv.clear_awaiting(email) try: @@ -1110,6 +1120,14 @@ async def _handle_compact( if provider is None: await self._reply(inbound, "ℹ️ There's no conversation to compact yet.") return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # unbounded wait below. Informational, never an error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + await self._reply(inbound, compact_unsupported_reply(unsupported)) + return await provider.compact() await provider.wait_for_compaction() await self._reply(inbound, "🗜️ Context compacted.") diff --git a/src/kiro_crew/telegram/transport_dispatch.py b/src/kiro_crew/telegram/transport_dispatch.py index 733b5340dba..82d782bf5dc 100644 --- a/src/kiro_crew/telegram/transport_dispatch.py +++ b/src/kiro_crew/telegram/transport_dispatch.py @@ -43,6 +43,8 @@ from kiro_crew.messaging.attachments import cleanup as cleanup_attachments from kiro_crew.messaging.commands import ( YOLO_PHRASING_PLAIN, + compact_unsupported_backend, + compact_unsupported_reply, cron_command_reply, format_ttl, lists_host_state, @@ -2849,6 +2851,11 @@ async def _maybe_notice( """ pct = self.sessions.check_context_usage(session_key, provider) soft_pct = self.cfg.telegram.soft_threshold_pct + if pct >= soft_pct and compact_unsupported_backend(provider): + # Capability gate (#8156): the nudge advises /compact, which this + # backend refuses — it compacts on its own as context fills, so + # there is nothing for the user to act on. + return if pct >= soft_pct and not self._conv.is_awaiting(route): self._conv.set_awaiting(route) assert self.client is not None @@ -2890,6 +2897,15 @@ async def _handle_compact(self, route: tuple[str, str], chat_id: int) -> None: await self._reply(chat_id, "No active session to compact.", thread=thread) return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # 120s wait below. Informational, never an error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + await self._reply(chat_id, compact_unsupported_reply(unsupported), thread=thread) + return + status_id = await self._reply(chat_id, "🔄 Compacting context…", thread=thread) result_text: str | None = None try: diff --git a/src/kiro_crew/webex/transport_dispatch.py b/src/kiro_crew/webex/transport_dispatch.py index dfdb3ea490b..c4203e9a47f 100644 --- a/src/kiro_crew/webex/transport_dispatch.py +++ b/src/kiro_crew/webex/transport_dispatch.py @@ -57,6 +57,7 @@ from kiro_crew.messaging.approval import PendingApprovals, SessionApprovalDecider from kiro_crew.messaging.attachments import append_attachment_context from kiro_crew.messaging.attachments import cleanup as cleanup_attachments +from kiro_crew.messaging.commands import compact_unsupported_backend, compact_unsupported_reply from kiro_crew.messaging.dispatch import ( ChannelTurn, build_directive_consumer, @@ -1532,6 +1533,14 @@ async def _maybe_notice(self, inbound: "WebexInbound", session_key: str, provide """ route = _route_of(inbound) pct = self.sessions.check_context_usage(session_key, provider) + if pct >= self.cfg.webex.soft_threshold_pct: + # Capability gate (#8156): no forced compaction to run and the + # soft nudge's /compact advice cannot work — the backend compacts + # on its own as context fills. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("Webex: context notice skipped — %s compacts itself", unsupported) + return if pct >= self.cfg.webex.hard_threshold_pct: self._conv.clear_awaiting(route) ok, detail = await self._compact_provider(provider) @@ -1602,6 +1611,14 @@ async def _handle_compact(self, inbound: "WebexInbound") -> None: if provider is None: await self._reply(inbound, "ℹ️ There's no conversation to compact yet.") return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # bounded wait. Informational, never an error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + await self._reply(inbound, compact_unsupported_reply(unsupported)) + return ok, detail = await self._compact_provider(provider) await self._reply( inbound, diff --git a/src/kiro_crew/wecom/transport_dispatch.py b/src/kiro_crew/wecom/transport_dispatch.py index 6d98d9e09d9..9ccea704a06 100644 --- a/src/kiro_crew/wecom/transport_dispatch.py +++ b/src/kiro_crew/wecom/transport_dispatch.py @@ -33,6 +33,7 @@ from kiro_crew.history import mint_row_mid from kiro_crew.messaging.attachments import append_attachment_context from kiro_crew.messaging.attachments import cleanup as cleanup_attachments +from kiro_crew.messaging.commands import compact_unsupported_backend from kiro_crew.messaging.dispatch import ( ChannelTurn, build_directive_consumer, @@ -421,6 +422,14 @@ async def _maybe_notice(self, inbound: "WeComInbound", session_key: str, provide """ userid = inbound.userid pct = self.sessions.check_context_usage(session_key, provider) + if pct >= self.cfg.wecom.soft_threshold_pct: + # Capability gate (#8156): no forced compaction to run and the + # soft nudge's /compact advice cannot work — the backend compacts + # on its own as context fills. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("WeCom: context notice skipped — %s compacts itself", unsupported) + return if pct >= self.cfg.wecom.hard_threshold_pct: self._conv.clear_awaiting(userid) try: @@ -634,6 +643,17 @@ async def _handle_compact(self, inbound: "WeComInbound") -> None: if provider is None: await self.client.say(inbound, "ℹ️ 当前没有可压缩的对话。") return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # unbounded wait below. Informational (this surface speaks Chinese; + # the wording translates ``compact_unsupported_reply``), never an + # error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("WeCom: manual /compact declined — %s compacts itself", unsupported) + await self.client.say(inbound, "ℹ️ 当前后端会自动压缩上下文,无需手动 /compact。") + return await provider.compact() await provider.wait_for_compaction() await self.client.say(inbound, "🗜️ 已压缩上下文。") diff --git a/src/kiro_crew/weixin/transport_dispatch.py b/src/kiro_crew/weixin/transport_dispatch.py index 59bd4408b38..fad94d9dc5a 100644 --- a/src/kiro_crew/weixin/transport_dispatch.py +++ b/src/kiro_crew/weixin/transport_dispatch.py @@ -37,6 +37,7 @@ from kiro_crew.history import mint_row_mid from kiro_crew.messaging.attachments import append_attachment_context from kiro_crew.messaging.attachments import cleanup as cleanup_attachments +from kiro_crew.messaging.commands import compact_unsupported_backend from kiro_crew.messaging.dispatch import ( ChannelTurn, build_directive_consumer, @@ -90,6 +91,9 @@ _COMPACT_NOTHING = "ℹ️ 当前没有可压缩的对话。" _COMPACT_DONE = "🗜️ 已压缩上下文。" _COMPACT_FAILED = "⚠️ 压缩失败,请重试。" +#: This surface speaks Chinese; the wording translates +#: ``messaging.commands.compact_unsupported_reply`` (#8156). +_COMPACT_AUTO_MANAGED = "ℹ️ 当前后端会自动压缩上下文,无需手动 /compact。" class WeixinDispatcher: @@ -473,6 +477,14 @@ async def _maybe_notice(self, user_id: str, session_key: str, provider: Any) -> pct = self.sessions.check_context_usage(session_key, provider) hard = getattr(self.cfg.weixin, "hard_threshold_pct", 95) soft = getattr(self.cfg.weixin, "soft_threshold_pct", 80) + if pct >= soft: + # Capability gate (#8156): no forced compaction to run and the + # soft nudge's /compact advice cannot work — the backend compacts + # on its own as context fills. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("weixin: context notice skipped — %s compacts itself", unsupported) + return if pct >= hard: self._conv.clear_awaiting(user_id) try: @@ -501,6 +513,15 @@ async def _handle_compact(self, user_id: str) -> None: if provider is None: await self._say(user_id, _COMPACT_NOTHING) return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # unbounded wait below. Informational, never an error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("weixin: manual /compact declined — %s compacts itself", unsupported) + await self._say(user_id, _COMPACT_AUTO_MANAGED) + return await provider.compact() await provider.wait_for_compaction() await self._say(user_id, _COMPACT_DONE) diff --git a/src/kiro_crew/whatsapp/commands.py b/src/kiro_crew/whatsapp/commands.py index 376ac521562..cf45a07dbfd 100644 --- a/src/kiro_crew/whatsapp/commands.py +++ b/src/kiro_crew/whatsapp/commands.py @@ -156,6 +156,14 @@ class WhatsAppCommand: COMPACT_BUSY_TEXT = "Still working on the last message; try /compact again shortly." COMPACT_NOTHING_TEXT = "There's no conversation to compact yet." COMPACT_FAILED_TEXT = "Couldn't compact the context; please try again." +#: The capability refusal (#8156): informational, never an error. This surface +#: keeps its plain-text voice; the wording tracks +#: ``messaging.commands.compact_unsupported_reply``. +COMPACT_AUTO_MANAGED_TEXT = ( + "This backend manages compaction automatically; it summarizes the " + "conversation on its own as context fills, so manual /compact isn't " + "needed (and isn't supported) here." +) #: The hard-threshold notice, sent AFTER the automatic compaction it reports. COMPACT_AUTO_TEXT = "Context was near its limit, so it was compacted automatically." #: The soft-threshold nudge, sent once per conversation until a compaction or a diff --git a/src/kiro_crew/whatsapp/transport_dispatch.py b/src/kiro_crew/whatsapp/transport_dispatch.py index 7d560dece66..e493e01564c 100644 --- a/src/kiro_crew/whatsapp/transport_dispatch.py +++ b/src/kiro_crew/whatsapp/transport_dispatch.py @@ -21,6 +21,7 @@ parse_approval_reply, pending_for, ) +from kiro_crew.messaging.commands import compact_unsupported_backend from kiro_crew.messaging.conversation import ConversationState from kiro_crew.messaging.dispatch import ( ChannelTurn, @@ -32,6 +33,7 @@ from kiro_crew.messaging.link import build_dm_session_key, seed_generation from kiro_crew.messaging.transport import InboundMessage from kiro_crew.whatsapp.commands import ( + COMPACT_AUTO_MANAGED_TEXT, COMPACT_AUTO_TEXT, COMPACT_BUSY_TEXT, COMPACT_FAILED_TEXT, @@ -244,6 +246,15 @@ async def _handle_compact(self, scope: str) -> None: if provider is None: await self._say(scope, COMPACT_NOTHING_TEXT) return + # Capability gate (#8156, mirroring the dashboard's #7800 gate): a + # backend that cannot serve a manual /compact treats the prompt as + # ordinary text and never answers, so dispatching would strand the + # unbounded wait below. Informational, never an error. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug("whatsapp: manual /compact declined — %s compacts itself", unsupported) + await self._say(scope, COMPACT_AUTO_MANAGED_TEXT) + return await provider.compact() await provider.wait_for_compaction() await self._say(scope, COMPACTED_TEXT) @@ -471,6 +482,17 @@ async def _maybe_notice( pct = self.sessions.check_context_usage(session_key, provider) may_speak = not unprompted and not delivery_is_muted(self.sessions, session_key, "whatsapp") wa = self.cfg.whatsapp + if pct >= wa.soft_threshold_pct: + # Capability gate (#8156): no forced compaction to run and the + # soft nudge's /compact advice cannot work — the backend compacts + # on its own as context fills. + unsupported = compact_unsupported_backend(provider) + if unsupported: + logger.debug( + "whatsapp: context notice skipped — %s compacts itself", + unsupported, + ) + return if pct >= wa.hard_threshold_pct: self._conv.clear_awaiting(scope) try: diff --git a/test/test_discord.py b/test/test_discord.py index 567f5208cc7..6874a6daa2d 100644 --- a/test/test_discord.py +++ b/test/test_discord.py @@ -2548,6 +2548,32 @@ async def test_compact_uses_try_acquire_and_releases(self) -> None: visible = " ".join([text for text, _ in cli.sent] + [text for _, text, _ in cli.edits]) assert "Context compacted" in visible + @pytest.mark.asyncio + async def test_compact_declined_on_auto_managed_backend(self) -> None: + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + d, cli, sess = _dispatcher({"u1"}) + calls: list[int] = [] + + async def _compact(context: str = "") -> None: + calls.append(1) + + sess._gp.compact = _compact + sess._gp.manual_compact_unsupported_backend = "kas" + await d.handle_message(self._msg("!compact")) + visible = " ".join([text for text, _ in cli.sent] + [text for _, text, _ in cli.edits]) + assert "manages compaction automatically" in visible + assert calls == [] + + @pytest.mark.asyncio + async def test_compact_none_capability_preserves_dispatch(self) -> None: + # The ABC's None (supported) default keeps the existing dispatch. + d, cli, sess = _dispatcher({"u1"}) + sess._gp.manual_compact_unsupported_backend = None + await d.handle_message(self._msg("!compact")) + visible = " ".join([text for text, _ in cli.sent] + [text for _, text, _ in cli.edits]) + assert "Context compacted" in visible + @pytest.mark.asyncio async def test_compact_summary_body_is_not_sent(self) -> None: d, cli, sess = _dispatcher({"u1"}) @@ -3142,6 +3168,18 @@ async def test_soft_threshold_nudges(self) -> None: assert any("!compact" in s[0] for s in cli.sent) + @pytest.mark.asyncio + async def test_soft_nudge_suppressed_on_auto_managed_backend(self) -> None: + # The nudge advises !compact, which this backend refuses — it compacts + # on its own, so there is nothing for the user to act on (#8156). + d, cli, sess = _dispatcher({"u1"}) + sess.check_context_usage = lambda key, provider: 85.0 + provider = SimpleNamespace(manual_compact_unsupported_backend="kas") + + await d._maybe_notice("chan1", "scope1", "key", provider) + + assert cli.sent == [] + @pytest.mark.asyncio async def test_below_soft_threshold_stays_silent(self) -> None: d, cli, sess = _dispatcher({"u1"}) diff --git a/test/test_feishu_dispatch.py b/test/test_feishu_dispatch.py index 90f9233b529..e89d58583d4 100644 --- a/test/test_feishu_dispatch.py +++ b/test/test_feishu_dispatch.py @@ -1025,3 +1025,61 @@ async def test_dm_turn_keeps_full_context(self) -> None: ) assert ctx.minimal == [False], ctx.minimal + + +# ------------------------------------------------------------------ +# Tests: the /compact capability gate (#8156) +# ------------------------------------------------------------------ + + +class TestCompactCapabilityGate: + @pytest.mark.asyncio + async def test_compact_declined_on_auto_managed_backend(self) -> None: + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + provider = FakeProvider([]) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("/compact")) + + assert provider.compacted is False + assert any("自动压缩上下文" in c for _, c in client.replies) + + @pytest.mark.asyncio + async def test_compact_none_capability_preserves_dispatch(self) -> None: + # The ABC's None (supported) default keeps the existing dispatch. + provider = FakeProvider([]) + provider.manual_compact_unsupported_backend = None + sessions = FakeSessions(provider) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("/compact")) + + assert provider.compacted is True + + @pytest.mark.asyncio + async def test_thresholds_decline_silently_on_auto_managed_backend(self) -> None: + # Hard: no forced compaction; soft: no /compact nudge — the backend + # compacts on its own as context fills (#8156). + provider = FakeProvider( + [ + AcpEvent(kind=EVENT_TEXT_CHUNK, text="answer"), + AcpEvent(kind=EVENT_COMPLETE), + ] + ) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider, ctx_pct=96.0) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("one", message_id="m1")) + assert provider.compacted is False + + sessions._ctx_pct = 85.0 + await d.handle_message(_inbound("two", message_id="m2")) + assert not any("已自动压缩" in c for _, c in client.replies) + assert not any("对话上下文已较长" in c for _, c in client.replies) diff --git a/test/test_imessage_dispatch.py b/test/test_imessage_dispatch.py index 3cc2e5f8691..35dc42a96ab 100644 --- a/test/test_imessage_dispatch.py +++ b/test/test_imessage_dispatch.py @@ -206,6 +206,34 @@ async def test_compact_runs_and_releases_the_session(self) -> None: assert sessions.released == [key] assert "compacted" in client.sent[0] + @pytest.mark.asyncio + async def test_compact_declined_on_auto_managed_backend(self) -> None: + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + dispatcher, client, sessions = _dispatcher() + key = dispatcher._session_key(HANDLE) + provider = FakeProvider() + provider.manual_compact_unsupported_backend = "kas" + sessions.providers[key] = provider + sessions.sessions.add(key) + await dispatcher.handle_message(_inbound("/compact")) + assert provider.compacted == 0 + assert sessions.released == [key] + assert "manages compaction automatically" in client.sent[0] + assert "`" not in client.sent[0] # iMessage speech carries no markdown + + @pytest.mark.asyncio + async def test_compact_none_capability_preserves_dispatch(self) -> None: + # The ABC's None (supported) default keeps the existing dispatch. + dispatcher, client, sessions = _dispatcher() + key = dispatcher._session_key(HANDLE) + provider = FakeProvider() + provider.manual_compact_unsupported_backend = None + sessions.providers[key] = provider + sessions.sessions.add(key) + await dispatcher.handle_message(_inbound("/compact")) + assert provider.compacted == 1 + @pytest.mark.asyncio async def test_compact_on_a_busy_session_asks_the_user_to_retry(self) -> None: # Compacting while a turn is mutating the same session races the @@ -309,6 +337,20 @@ async def test_the_hard_threshold_compacts_automatically(self) -> None: assert provider.compacted == 1 assert "compacted automatically" in client.sent[0] + @pytest.mark.asyncio + async def test_thresholds_decline_silently_on_auto_managed_backend(self) -> None: + # Hard: no forced compaction; soft: no /compact nudge — the backend + # compacts on its own as context fills (#8156). + dispatcher, client, sessions = _dispatcher() + provider = FakeProvider() + provider.manual_compact_unsupported_backend = "kas" + sessions.usage_pct = 99.0 + await dispatcher._maybe_notice(_inbound("x"), "k", provider) + assert provider.compacted == 0 + sessions.usage_pct = 85.0 + await dispatcher._maybe_notice(_inbound("x"), "k", provider) + assert client.sent == [] + @pytest.mark.asyncio async def test_a_failed_auto_compaction_is_not_announced_as_success(self) -> None: dispatcher, client, sessions = _dispatcher() diff --git a/test/test_messaging_commands.py b/test/test_messaging_commands.py index ffb38ead454..bb816c246cf 100644 --- a/test/test_messaging_commands.py +++ b/test/test_messaging_commands.py @@ -1055,3 +1055,37 @@ def test_the_spawn_listing_really_does_ignore_the_session(self) -> None: out = commands.spawn_task_reply("list", manager, "telegram:kirocrew:direct:7") assert out is not None assert "mine" in out and "somebody elses" in out + + +# ── the manual-/compact capability gate (#8156) ─────────────────────────────── + + +class TestCompactUnsupportedBackend: + """The channel half of the dashboard's manual-/compact capability gate.""" + + def test_a_named_unsupported_backend_is_returned(self) -> None: + provider = SimpleNamespace(manual_compact_unsupported_backend="kas") + assert commands.compact_unsupported_backend(provider) == "kas" + + def test_an_absent_property_reads_as_supported(self) -> None: + assert commands.compact_unsupported_backend(SimpleNamespace()) is None + + def test_a_none_value_reads_as_supported(self) -> None: + provider = SimpleNamespace(manual_compact_unsupported_backend=None) + assert commands.compact_unsupported_backend(provider) is None + + def test_an_empty_string_reads_as_supported(self) -> None: + provider = SimpleNamespace(manual_compact_unsupported_backend="") + assert commands.compact_unsupported_backend(provider) is None + + def test_a_mocked_truthy_non_str_never_reads_as_a_refusal(self) -> None: + # A MagicMock answers every attribute with a truthy mock; the ABC's + # contract is a non-empty str, so anything else must pass through. + assert commands.compact_unsupported_backend(MagicMock()) is None + + def test_the_reply_names_the_backend_and_reads_as_information(self) -> None: + reply = commands.compact_unsupported_reply("kas") + assert "kas" in reply + assert "automatically" in reply + # Informational, never an error. + assert "❌" not in reply and "⚠️" not in reply diff --git a/test/test_slack_handler.py b/test/test_slack_handler.py index 0f47fab7122..7c3c14d8982 100644 --- a/test/test_slack_handler.py +++ b/test/test_slack_handler.py @@ -2739,6 +2739,42 @@ async def test_compact_no_session_replies_no_session(self): texts = self._posted_texts(slack) assert any("No active session" in t for t in texts) + @pytest.mark.asyncio + async def test_compact_declined_on_auto_managed_backend(self): + # A backend that cannot serve /compact (the provider names it via + # manual_compact_unsupported_backend) gets the informational reply and + # compact() is NEVER dispatched (#8156). + provider = self._make_provider_with_compact() + calls = [] + + async def _compact(context=""): + calls.append(1) + + provider.compact = _compact + provider.manual_compact_unsupported_backend = "kas" + sessions = self._make_sessions_with_active(provider) + slack = MockSlackClient() + + await handle_message(slack, sessions, "C1", "!compact", "thread1", "msg1", "U_OWNER") + + texts = self._posted_texts(slack) + assert any("manages compaction automatically" in t for t in texts) + assert not any("Compacting context" in t for t in texts) + assert calls == [] + + @pytest.mark.asyncio + async def test_compact_none_capability_preserves_dispatch(self): + # The ABC's None (supported) default keeps the existing dispatch. + provider = self._make_provider_with_compact() + provider.manual_compact_unsupported_backend = None + sessions = self._make_sessions_with_active(provider) + slack = MockSlackClient() + + await handle_message(slack, sessions, "C1", "!compact", "thread1", "msg1", "U_OWNER") + + texts = self._posted_texts(slack) + assert any("✅" in t for t in texts) + @pytest.mark.asyncio async def test_compact_failed_reports_error(self): provider = self._make_provider_with_compact( diff --git a/test/test_teams_dispatch.py b/test/test_teams_dispatch.py index e184e70f62a..79418a892a7 100644 --- a/test/test_teams_dispatch.py +++ b/test/test_teams_dispatch.py @@ -332,6 +332,39 @@ async def test_hard_threshold_forces_compaction(self) -> None: assert provider.compacted is True assert any("compacted" in content for (_, content, _) in client.sent) + @pytest.mark.asyncio + async def test_hard_threshold_declines_silently_on_auto_managed_backend(self) -> None: + # No /compact to dispatch and no notice: the backend compacts on its + # own as context fills (#8156). + provider = FakeProvider( + [AcpEvent(kind=EVENT_TEXT_CHUNK, text="answer"), AcpEvent(kind=EVENT_COMPLETE)] + ) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider, ctx_pct=96.0) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("hello")) + + assert provider.compacted is False + assert not any("compacted" in content for (_, content, _) in client.sent) + + @pytest.mark.asyncio + async def test_soft_nudge_suppressed_on_auto_managed_backend(self) -> None: + # The nudge advises /compact, which this backend refuses — it compacts + # on its own, so there is nothing for the user to act on (#8156). + provider = FakeProvider( + [AcpEvent(kind=EVENT_TEXT_CHUNK, text="answer"), AcpEvent(kind=EVENT_COMPLETE)] + ) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider, ctx_pct=85.0) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("hello")) + + assert not any("/compact" in content for (_, content, _) in client.sent) + class TestCommands: @pytest.mark.asyncio @@ -373,6 +406,36 @@ async def test_compact_command(self) -> None: assert sessions.released == [key] assert client.sent == [("CONV", "🗜️ Context compacted.", _SVC)] + @pytest.mark.asyncio + async def test_compact_declined_on_auto_managed_backend(self) -> None: + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + provider = FakeProvider([]) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("/compact")) + + key = d._session_key(_EMAIL) + assert provider.compacted is False + assert sessions.released == [key] # the acquired semaphore is handed back + assert any("manages compaction automatically" in content for (_, content, _) in client.sent) + + @pytest.mark.asyncio + async def test_compact_none_capability_preserves_dispatch(self) -> None: + # The ABC's None (supported) default keeps the existing dispatch. + provider = FakeProvider([]) + provider.manual_compact_unsupported_backend = None + sessions = FakeSessions(provider) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("/compact")) + + assert provider.compacted is True + @pytest.mark.asyncio async def test_stable_session_key_per_user(self) -> None: sessions = FakeSessions(FakeProvider([])) diff --git a/test/test_telegram.py b/test/test_telegram.py index 1d0d639dde4..d7c1d2319f7 100644 --- a/test/test_telegram.py +++ b/test/test_telegram.py @@ -2887,6 +2887,47 @@ async def _go() -> None: assert sess.released == ["telegram:kirocrew:direct:7"] # and released it in finally assert any("Compact" in s[0] for s in cli.sent) or any("Compact" in e[1] for e in cli.edits) + def test_compact_declined_on_auto_managed_backend(self) -> None: + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + d, cli, sess = _dispatcher({7}) + calls: list[int] = [] + + async def _compact(context: str = "") -> None: + calls.append(1) + + sess._gp.compact = _compact + sess._gp.manual_compact_unsupported_backend = "kas" + + async def _go() -> None: + await d.handle_message( + InboundMessage( + channel_type="telegram", user_id="7", conversation_id="7", text="/compact" + ) + ) + + asyncio.run(_go()) + visible = " ".join([text for text, _ in cli.sent] + [text for _, text, _ in cli.edits]) + assert "manages compaction automatically" in visible + assert calls == [] + assert sess.released == ["telegram:kirocrew:direct:7"] # semaphore still handed back + + def test_compact_none_capability_preserves_dispatch(self) -> None: + # The ABC's None (supported) default keeps the existing dispatch. + d, cli, sess = _dispatcher({7}) + sess._gp.manual_compact_unsupported_backend = None + + async def _go() -> None: + await d.handle_message( + InboundMessage( + channel_type="telegram", user_id="7", conversation_id="7", text="/compact" + ) + ) + + asyncio.run(_go()) + visible = " ".join([text for text, _ in cli.sent] + [text for _, text, _ in cli.edits]) + assert "Context compacted" in visible + def test_compact_summary_body_is_not_sent(self) -> None: d, cli, sess = _dispatcher({7}) @@ -5141,6 +5182,17 @@ def test_soft_threshold_nudges(self) -> None: assert any("/compact" in s[0] for s in cli.sent) + def test_soft_nudge_suppressed_on_auto_managed_backend(self) -> None: + # The nudge advises /compact, which this backend refuses — it compacts + # on its own, so there is nothing for the user to act on (#8156). + d, cli, sess = _dispatcher({7}) + sess.check_context_usage = lambda key, provider: 85.0 + provider = SimpleNamespace(manual_compact_unsupported_backend="kas") + + asyncio.run(d._maybe_notice(7, ("direct", "7"), "key", provider)) + + assert cli.sent == [] + def test_below_soft_threshold_stays_silent(self) -> None: d, cli, sess = _dispatcher({7}) sess.check_context_usage = lambda key, provider: 10.0 diff --git a/test/test_webex_dispatch.py b/test/test_webex_dispatch.py index 342c1e05568..00c39341bc7 100644 --- a/test/test_webex_dispatch.py +++ b/test/test_webex_dispatch.py @@ -427,6 +427,39 @@ async def test_hard_threshold_forces_compaction(self) -> None: assert provider.compacted is True assert any("compacted" in m for (_, m) in client.sent) + @pytest.mark.asyncio + async def test_hard_threshold_declines_silently_on_auto_managed_backend(self) -> None: + # No /compact to dispatch and no notice: the backend compacts on its + # own as context fills (#8156). + provider = FakeProvider( + [AcpEvent(kind=EVENT_TEXT_CHUNK, text="answer"), AcpEvent(kind=EVENT_COMPLETE)] + ) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider, ctx_pct=96.0) # >= hard (95) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("hello")) + + assert provider.compacted is False + assert not any("compacted" in m for (_, m) in client.sent) + + @pytest.mark.asyncio + async def test_soft_nudge_suppressed_on_auto_managed_backend(self) -> None: + # The nudge advises /compact, which this backend refuses — it compacts + # on its own, so there is nothing for the user to act on (#8156). + provider = FakeProvider( + [AcpEvent(kind=EVENT_TEXT_CHUNK, text="answer"), AcpEvent(kind=EVENT_COMPLETE)] + ) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider, ctx_pct=85.0) # >= soft (80), < hard (95) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("hello")) + + assert not any("/compact" in m for (_, m) in client.sent) + # ------------------------------------------------------------------ # Tests: commands @@ -473,6 +506,36 @@ async def test_compact_command(self) -> None: assert sessions.released == [key] assert client.sent == [("ROOM", "🗜️ Context compacted.")] + @pytest.mark.asyncio + async def test_compact_declined_on_auto_managed_backend(self) -> None: + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + provider = FakeProvider([]) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("/compact")) + + key = d._session_key(_EMAIL) + assert provider.compacted is False + assert sessions.released == [key] # the acquired semaphore is handed back + assert any("manages compaction automatically" in m for (_, m) in client.sent) + + @pytest.mark.asyncio + async def test_compact_none_capability_preserves_dispatch(self) -> None: + # The ABC's None (supported) default keeps the existing dispatch. + provider = FakeProvider([]) + provider.manual_compact_unsupported_backend = None + sessions = FakeSessions(provider) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("/compact")) + + assert provider.compacted is True + @pytest.mark.asyncio async def test_compact_refused_while_turn_busy(self) -> None: provider = FakeProvider([]) diff --git a/test/test_wecom_dispatch.py b/test/test_wecom_dispatch.py index a1c0280ea45..f174ebf9469 100644 --- a/test/test_wecom_dispatch.py +++ b/test/test_wecom_dispatch.py @@ -413,6 +413,69 @@ async def test_compact_command(self) -> None: assert sessions.released == [key] assert client.said == ["🗜️ 已压缩上下文。"] + @pytest.mark.asyncio + async def test_compact_declined_on_auto_managed_backend(self) -> None: + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + provider = FakeProvider([]) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("/compact")) + + key = d._session_key("Wei") + assert provider.compacted is False + assert sessions.released == [key] # the acquired semaphore is handed back + assert any("自动压缩上下文" in s for s in client.said) + + @pytest.mark.asyncio + async def test_compact_none_capability_preserves_dispatch(self) -> None: + # The ABC's None (supported) default keeps the existing dispatch. + provider = FakeProvider([]) + provider.manual_compact_unsupported_backend = None + sessions = FakeSessions(provider) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("/compact")) + + assert provider.compacted is True + + @pytest.mark.asyncio + async def test_hard_threshold_declines_silently_on_auto_managed_backend(self) -> None: + # No /compact to dispatch and no notice: the backend compacts on its + # own as context fills (#8156). + provider = FakeProvider( + [AcpEvent(kind=EVENT_TEXT_CHUNK, text="answer"), AcpEvent(kind=EVENT_COMPLETE)] + ) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider, ctx_pct=96.0) # >= hard (95) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("hello")) + + assert provider.compacted is False + assert not any("已自动压缩" in c for _chat, c in client.pushed) + + @pytest.mark.asyncio + async def test_soft_nudge_suppressed_on_auto_managed_backend(self) -> None: + # The nudge advises /compact, which this backend refuses — it compacts + # on its own, so there is nothing for the user to act on (#8156). + provider = FakeProvider( + [AcpEvent(kind=EVENT_TEXT_CHUNK, text="answer"), AcpEvent(kind=EVENT_COMPLETE)] + ) + provider.manual_compact_unsupported_backend = "kas" + sessions = FakeSessions(provider, ctx_pct=85.0) # >= soft (80), < hard (95) + client = FakeClient() + d = _dispatcher(sessions, FakeCtx(), client) + + await d.handle_message(_inbound("hello")) + + assert not any("对话上下文已较长" in c for _chat, c in client.pushed) + @pytest.mark.asyncio async def test_compact_refused_while_turn_busy(self) -> None: provider = FakeProvider([]) diff --git a/test/test_weixin_dispatch.py b/test/test_weixin_dispatch.py index eefc5854cb8..d9ba863d9f3 100644 --- a/test/test_weixin_dispatch.py +++ b/test/test_weixin_dispatch.py @@ -399,6 +399,50 @@ def test_compact_command_compacts_without_a_turn(tmp_path): assert sessions.released == 1 # acquired for compaction, then released +def test_compact_command_declined_on_auto_managed_backend(tmp_path): + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + provider = FakeProvider() + provider.manual_compact_unsupported_backend = "kas" + d, client, sessions = _make(tmp_path, provider=provider) + asyncio.run(d.handle_message(_msg("/compact"))) + assert provider.compacted is False + assert sessions.released == 1 # the acquired semaphore is handed back + assert any("自动压缩上下文" in s["text"] for s in client.sent) + + +def test_compact_none_capability_preserves_dispatch(tmp_path): + # The ABC's None (supported) default keeps the existing dispatch. + provider = FakeProvider() + provider.manual_compact_unsupported_backend = None + d, client, sessions = _make(tmp_path, provider=provider) + asyncio.run(d.handle_message(_msg("/compact"))) + assert provider.compacted is True + + +def test_hard_threshold_declines_silently_on_auto_managed_backend(tmp_path): + # No /compact to dispatch and no notice: the backend compacts on its own + # as context fills (#8156). + provider = FakeProvider() + provider.manual_compact_unsupported_backend = "kas" + d, client, sessions = _make(tmp_path, provider=provider) + sessions.check_context_usage = lambda k, p: 99.0 # type: ignore[assignment] + asyncio.run(d.handle_message(_msg("long convo"))) + assert provider.compacted is False + assert not any("已自动压缩" in s["text"] for s in client.sent) + + +def test_soft_nudge_suppressed_on_auto_managed_backend(tmp_path): + # The nudge advises /compact, which this backend refuses — it compacts on + # its own, so there is nothing for the user to act on (#8156). + provider = FakeProvider() + provider.manual_compact_unsupported_backend = "kas" + d, client, sessions = _make(tmp_path, provider=provider) + sessions.check_context_usage = lambda k, p: 85.0 # type: ignore[assignment] + asyncio.run(d.handle_message(_msg("one"))) + assert not any("上下文已较长" in s["text"] for s in client.sent) + + def test_busy_session_does_not_start_a_second_turn(tmp_path): provider = FakeProvider() d, client, _ = _make(tmp_path, provider=provider, busy=True) diff --git a/test/test_whatsapp_dispatch.py b/test/test_whatsapp_dispatch.py index 2ac11146431..86b18a348cb 100644 --- a/test/test_whatsapp_dispatch.py +++ b/test/test_whatsapp_dispatch.py @@ -17,6 +17,7 @@ from kiro_crew.messaging.transport import InboundMessage from kiro_crew.session_allocation import SessionClosingError from kiro_crew.whatsapp.commands import ( + COMPACT_AUTO_MANAGED_TEXT, COMPACT_AUTO_TEXT, COMPACT_BUSY_TEXT, COMPACT_FAILED_TEXT, @@ -324,6 +325,49 @@ def test_compact_command_compacts_in_place_without_a_turn(): assert sessions.released == 1, "the turn semaphore must always be handed back" +def test_compact_command_declined_on_auto_managed_backend(): + # A backend that cannot serve /compact gets the informational reply and + # compact() is NEVER dispatched (#8156). + provider = FakeProvider() + provider.manual_compact_unsupported_backend = "kas" + d, _client, sessions, transport = _make(provider=provider) + asyncio.run(d.handle_message(_msg("/compact"))) + assert (provider.compacts, provider.waits) == (0, 0) + assert [t for _, t in transport.sent] == [COMPACT_AUTO_MANAGED_TEXT] + assert sessions.released == 1, "the turn semaphore must always be handed back" + + +def test_compact_none_capability_preserves_dispatch(): + # The ABC's None (supported) default keeps the existing dispatch. + provider = FakeProvider() + provider.manual_compact_unsupported_backend = None + d, _client, _sessions, transport = _make(provider=provider) + asyncio.run(d.handle_message(_msg("/compact"))) + assert (provider.compacts, provider.waits) == (1, 1) + assert [t for _, t in transport.sent] == [COMPACTED_TEXT] + + +def test_the_hard_threshold_declines_silently_on_auto_managed_backend(): + # No /compact to dispatch and no notice: the backend compacts on its own + # as context fills (#8156). + provider = FakeProvider("answered") + provider.manual_compact_unsupported_backend = "kas" + d, _client, _sessions, transport = _make(provider=provider, context_pct=96.0) + asyncio.run(d.handle_message(_msg("a long conversation"))) + assert (provider.compacts, provider.waits) == (0, 0) + assert [t for _, t in transport.sent] == ["answered"] + + +def test_the_soft_nudge_is_suppressed_on_auto_managed_backend(): + # The nudge advises /compact, which this backend refuses — it compacts on + # its own, so there is nothing for the user to act on (#8156). + provider = FakeProvider("answered") + provider.manual_compact_unsupported_backend = "kas" + d, _client, _sessions, transport = _make(provider=provider, context_pct=85.0) + asyncio.run(d.handle_message(_msg("first"))) + assert [t for _, t in transport.sent] == ["answered"] + + # ── dispatcher: busy / steering ───────────────────────────────────────────── def test_busy_session_without_steer_asks_to_resend(): provider = FakeProvider()