diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index d579c243d1ea..c50e854e84e4 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -2843,24 +2843,72 @@ async def send_clarify( registry maps ids back to the real strings on the answer. """ if choices and self.descriptor.supports_op("prompt"): + choice_labels = [str(choice) for choice in choices] options = [ - {"id": f"c{i}", "label": str(choice)[:75]} - for i, choice in enumerate(choices) + {"id": f"c{i}", "label": f"Choose {i + 1}"} + for i in range(len(choice_labels)) ] options.append({"id": "other", "label": "✏️ Other (type your answer)"}) + details = "\n\n".join( + f"{i + 1}. {label}" for i, label in enumerate(choice_labels) + ) + prompt_text = f"❓ {question}\n\n{details}" + len_fn = self.message_len_fn_for_chat(chat_id) + max_length = self.max_message_length_for_chat(chat_id) + if len_fn(prompt_text) > max_length: + from tools import clarify_gateway as _cg + + is_multi = False + try: + with _cg._lock: + entry = _cg._entries.get(clarify_id) + is_multi = bool( + entry and getattr(entry, "multi_select", False) + ) + except Exception: + is_multi = False + if is_multi: + response_hint = ( + "Multiple selections allowed — reply with the numbers " + "separated by commas or spaces (e.g. \"1, 3\"), the option " + "text, or your own answer." + ) + else: + response_hint = ( + "Reply with the number, the option text, or your own answer." + ) + fallback_text = f"{prompt_text}\n\n{response_hint}" + _cg.mark_awaiting_text(clarify_id) + chunks = self.truncate_message( + fallback_text, + max_length, + len_fn=len_fn, + ) + chunk_metadata = dict(metadata or {}) + chunk_metadata["_interim_send"] = True + last_result = SendResult(success=True) + for chunk in chunks: + last_result = await self.send( + chat_id=chat_id, + content=chunk, + metadata=chunk_metadata, + ) + if not last_result.success: + return last_result + return last_result prompt_id = self._mint_prompt( "clarify", { "session_key": session_key, "clarify_id": clarify_id, - "choices": [str(c) for c in choices], + "choices": choice_labels, "chat_id": str(chat_id), }, ) result = await self._send_prompt( chat_id, prompt_kind="clarify", - text=f"❓ {question}", + text=prompt_text, prompt_id=prompt_id, options=options, metadata=metadata, diff --git a/tests/gateway/relay/test_relay_interactive.py b/tests/gateway/relay/test_relay_interactive.py index 853716d40c28..e6507880b805 100644 --- a/tests/gateway/relay/test_relay_interactive.py +++ b/tests/gateway/relay/test_relay_interactive.py @@ -144,10 +144,11 @@ async def test_slash_confirm_renders_three_options(): @pytest.mark.asyncio async def test_clarify_renders_choices_plus_other_with_positional_ids(): adapter, stub = _adapter() + long_choice = "staging — the safe one with a deliberately descriptive label " + "x" * 80 result = await adapter.send_clarify( "c1", "Which environment?", - ["staging — the safe one", "production"], + [long_choice, "production"], "cl-1", "sess:1", ) @@ -158,9 +159,111 @@ async def test_clarify_renders_choices_plus_other_with_positional_ids(): # Positional ids (choice text is arbitrary UTF-8; ids must be callback-safe). assert ids == ["c0", "c1", "other"] labels = [o["label"] for o in action["options"]] - assert labels[0].startswith("staging") + assert labels[:2] == ["Choose 1", "Choose 2"] + assert long_choice in action["content"] + assert "production" in action["content"] state = adapter._pending_prompts[action["prompt_id"]] - assert state["choices"] == ["staging — the safe one", "production"] + assert state["choices"] == [long_choice, "production"] + + +@pytest.mark.asyncio +async def test_clarify_falls_back_when_prompt_exceeds_platform_limit(monkeypatch): + adapter, stub = _adapter(platform="discord", max_message_length=2000) + choices = ["A" * 800, "B" * 800] + marked: list[str] = [] + monkeypatch.setattr( + "tools.clarify_gateway.mark_awaiting_text", lambda cid: marked.append(cid) + ) + + result = await adapter.send_clarify( + "c1", + "Q" * 500, + choices, + "cl-oversize", + "sess:oversize", + ) + + assert result.success is True + assert not [action for action in stub.sent if action["op"] == "prompt"] + sends = [action for action in stub.sent if action["op"] == "send"] + assert sends + assert all(len(action["content"]) <= 2000 for action in sends) + delivered = "".join(action["content"] for action in sends) + assert choices[0] in delivered + assert choices[1] in delivered + assert marked == ["cl-oversize"] + + +@pytest.mark.asyncio +async def test_oversize_multi_select_clarify_keeps_multi_select_instructions(): + from tools import clarify_gateway as cg + + clarify_id = "cl-oversize-multi" + session_key = "sess:oversize-multi" + choices = ["A" * 800, "B" * 800] + cg.register( + clarify_id, + session_key, + "Q" * 500, + choices, + multi_select=True, + ) + adapter, stub = _adapter(platform="discord", max_message_length=2000) + try: + result = await adapter.send_clarify( + "c1", + "Q" * 500, + choices, + clarify_id, + session_key, + ) + + assert result.success is True + delivered = "".join( + action["content"] for action in stub.sent if action["op"] == "send" + ) + assert "Multiple selections allowed" in delivered + assert 'separated by commas or spaces (e.g. "1, 3")' in delivered + finally: + cg.clear_session(session_key) + + +@pytest.mark.asyncio +async def test_oversize_clarify_fallback_does_not_seal_open_slack_draft(): + adapter, stub = _adapter( + platform="slack", + label="Slack", + max_message_length=2000, + supports_draft_streaming=True, + markdown_dialect="mrkdwn", + len_unit="unicode", + supported_ops=FULL_OPS + ("draft",), + ) + await adapter.send_draft( + "c1", + 17, + "partial assistant reply", + metadata={"message_id": "turn-1"}, + ) + open_drafts = dict(adapter._open_draft_by_chat) + stub.sent.clear() + + result = await adapter.send_clarify( + "c1", + "Q" * 500, + ["A" * 800, "B" * 800], + "cl-open-draft", + "sess:open-draft", + ) + + assert result.success is True + assert not [ + action + for action in stub.sent + if action["op"] == "draft" and action.get("final") + ] + assert adapter._open_draft_by_chat == open_drafts + assert [action for action in stub.sent if action["op"] == "send"] # ── the pending-prompt registry ──────────────────────────────────────────