diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 30f9c4765..9fb3c7ad1 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -1193,6 +1193,11 @@ def process_lark_goal_topic_event( content_format=content_format, execute=True, runner=reply_runner, + # A manager answer is bounded by the provider's request limit, + # not by the compact notification length: one answer is one + # message, and the bounded part sequence is the fallback for a + # body the provider itself cannot take. + short_message_limit=None, ) except LarkOutboundTextError: if not manager: diff --git a/loopx/extensions/lark/inbox_reply.py b/loopx/extensions/lark/inbox_reply.py index e0737dc7e..2d635c4b6 100644 --- a/loopx/extensions/lark/inbox_reply.py +++ b/loopx/extensions/lark/inbox_reply.py @@ -15,6 +15,7 @@ ) from .inbox_reactions import complete_lark_event_inbox_reactions from .outbound import ( + DEFAULT_LARK_TEXT_LIMIT, LARK_POST_REQUEST_MAX_BYTES, expected_lark_mention_identities, lark_markdown_post_content, @@ -280,8 +281,16 @@ def _deliver_lark_inbox_outbound( runner: CommandRunner = _default_runner, before_send: Callable[[str], Mapping[str, Any]] | None = None, delivery_attempt_recorder: Callable[[Mapping[str, str]], None] | None = None, + short_message_limit: int | None = DEFAULT_LARK_TEXT_LIMIT, ) -> dict[str, Any]: - """Deliver through one inbox-configured bot with exact provider readback.""" + """Deliver through one inbox-configured bot with exact provider readback. + + ``short_message_limit`` owns the compact, self-imposed length a delivery + keeps when it has no source message to answer. Only the chat-root + notification caller relies on it: 1200 is not a provider bound, so a + delivery that answers a captured source message is bounded by the provider's + own request limit instead of being cut by our own guess. + """ config = load_lark_event_inbox_config(project=project, config_path=config_path) if not config["enabled"]: @@ -313,7 +322,8 @@ def _deliver_lark_inbox_outbound( # Structured mentions retain the existing identity-verified text transport. markdown = content_format == "markdown" and not expected_lark_mention_identities(text) reply_text = normalize_lark_outbound_text( - text, limit=None if source_event is not None else 1200, + text, + limit=None if source_event is not None else short_message_limit, preserve_format=markdown, ) # Reject an oversized content lower bound before building a CLI argument. @@ -696,8 +706,14 @@ def reply_lark_event_inbox( runner: CommandRunner = _default_runner, before_send: Callable[[str], Mapping[str, Any]] | None = None, delivery_attempt_recorder: Callable[[Mapping[str, str]], None] | None = None, + short_message_limit: int | None = DEFAULT_LARK_TEXT_LIMIT, ) -> dict[str, Any]: - """Reply with the explicit inbox-configured bot and placement policy.""" + """Reply with the explicit inbox-configured bot and placement policy. + + An answer delivery passes ``short_message_limit=None`` to declare that it is + bounded by the provider's request limit rather than by the compact + notification length. + """ result = _deliver_lark_inbox_outbound( project=project, @@ -710,6 +726,7 @@ def reply_lark_event_inbox( runner=runner, before_send=before_send, delivery_attempt_recorder=delivery_attempt_recorder, + short_message_limit=short_message_limit, ) result.setdefault("content_format", "markdown" if content_format == "markdown" @@ -894,7 +911,12 @@ def send_lark_inbox_message( runner: CommandRunner = _default_runner, before_send: Callable[[str], Mapping[str, Any]] | None = None, ) -> dict[str, Any]: - """Send one verified chat-root message through the configured inbox bot.""" + """Send one verified chat-root message through the configured inbox bot. + + A chat-root notification keeps the compact self-imposed length: it is a + notice on the channel, not an answer, and the reader expects it to stay + short. + """ result = _deliver_lark_inbox_outbound( project=project, @@ -905,6 +927,7 @@ def send_lark_inbox_message( provider_preflight=provider_preflight, runner=runner, before_send=before_send, + short_message_limit=DEFAULT_LARK_TEXT_LIMIT, ) result["schema_version"] = "lark_outbound_message_v0" blocker = result.get("blocker") diff --git a/loopx/extensions/lark/manager_reply_parts.py b/loopx/extensions/lark/manager_reply_parts.py index 6be3ead7d..2d25a3a2f 100644 --- a/loopx/extensions/lark/manager_reply_parts.py +++ b/loopx/extensions/lark/manager_reply_parts.py @@ -273,6 +273,9 @@ def record_attempt(attempt: Mapping[str, Any], *, index=index) -> None: execute=True, runner=reply_runner, delivery_attempt_recorder=record_attempt, + # Part delivery is the fallback for a body the provider itself + # cannot take; a part is never cut by the notification length. + short_message_limit=None, ) if not _part_accepted(last): delivery_state.update( diff --git a/loopx/extensions/lark/manager_returns.py b/loopx/extensions/lark/manager_returns.py index cb0508083..5721a277d 100644 --- a/loopx/extensions/lark/manager_returns.py +++ b/loopx/extensions/lark/manager_returns.py @@ -117,6 +117,9 @@ def before_send(_intent): execute=True, before_send=before_send, delivery_attempt_recorder=delivery_attempt_recorder, + # A returned manager answer keeps the provider bound, not the compact + # notification length. + short_message_limit=None, **({"runner": runner} if runner else {}), ) diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index 0de07b6df..aa3309779 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -3025,3 +3025,89 @@ def ambiguous_runner(args: list[str]) -> dict[str, Any]: assert settled["status"] == "acknowledged" assert settled["delivery_parts_sent"] == MANAGER_REPLY_MAX_PARTS assert PART_ATTEMPT_KEY not in settled + + +def test_a_long_manager_answer_is_delivered_as_one_message( + tmp_path, monkeypatch, +): + """A 3000-character steward answer is one message, not a bounded sequence. + + The self-imposed 1200-character cap is not a provider limit: the transport + already accepts up to the 150 KB provider bound, and every reader-visible + split costs an extra message plus a part-sequence record. This pins the + single-message outcome so a later change cannot silently reintroduce the + split for an ordinary long answer. + """ + + from loopx.extensions.lark import goal_topic_runtime as runtime + + target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" + _seed_legacy_topic(target_path, binding_path) + original_decide = runtime.decide_lark_topic_event + state: dict[str, Any] = {} + body = "测" * 3000 + + def manager_decision(**kwargs): + result = original_decide(**kwargs) + result["route"].update( + conversation_kind="manager", ingress_mode="session_queue", + authority_mode="turn_authorized", + event_id=kwargs["event"]["event_id"], + connector={"response_policy": "topic_reply"}, + ) + return result + + monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) + monkeypatch.setattr( + runtime, + "ensure_lark_event_inbox_received_reaction", + lambda **kw: {"ok": True, "status": "already_received"}, + ) + + kwargs = { + "target_payload": read_goal_channel_targets(target_path), + "binding_payloads": {"goal-alpha": read_goal_channel_binding(binding_path)}, + "event": { + "event_id": "evt_long_answer", + "message_id": "om_long_answer", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-09-21T15:20:00Z", + "content": "@linkmacbot report", + "mentioned": True, + "sender_type": "user", + }, + "runtime_root": tmp_path / "runtime", + "answer": lambda route, text: { + "response_text": body, + "effect_receipt": runtime._session_turn_effect(route), + }, + "reply_runner": _reply_runner(state), + } + + result = runtime.process_lark_goal_topic_event(**kwargs) + + sent = [ + call[call.index("--text") + 1] + if "--text" in call + else call[call.index("--content") + 1] + for call in state["calls"] + if "+messages-reply" in call and "--dry-run" not in call + ] + assert result["status"] in {"replied_and_acknowledged", "acknowledged"} + assert len(sent) == 1, sent + # One message: the whole body in the rich-text envelope, with no part + # marker and no part record. + payload = json.loads(sent[0]) + delivered = payload["zh_cn"]["content"][0][0]["text"] + assert delivered == body + from loopx.extensions.lark.manager_reply_delivery import delivery_path + + saved = json.loads( + delivery_path( + project=kwargs["runtime_root"], + config_path=Path(result["inbox_config_ref"]), + message_id="om_long_answer", + ).read_text() + ) + assert "delivery_part_count" not in saved diff --git a/tests/extensions/test_lark_inbox_reactions.py b/tests/extensions/test_lark_inbox_reactions.py index 1d448fee0..62e7b63fe 100644 --- a/tests/extensions/test_lark_inbox_reactions.py +++ b/tests/extensions/test_lark_inbox_reactions.py @@ -1439,6 +1439,42 @@ def test_top_level_send_rejects_overlong_text_instead_of_truncating_mention( assert runner.calls == [] +def test_an_answer_delivery_is_bounded_by_the_provider_not_by_the_notice_length( + tmp_path: Path, +) -> None: + """The compact notification length must not cut an answer delivery. + + The same over-limit body the chat-root notification rejects is accepted on + the answer path, because the 1200 characters are a notification shape and + not a provider bound: the provider request limit is what actually bounds it. + """ + + config, _, project = _fixture(tmp_path, lifecycle=False) + runner = ReplyRunner() + text = "x" * 1160 + 'Public Reviewer please review' + + with pytest.raises(ValueError, match="exceeds the 1200-character"): + send_lark_inbox_message( + project=project, + config_path=config, + text=text, + execute=True, + runner=runner, + ) + + accepted = reply_lark_event_inbox( + project=project, + config_path=config, + message_id=None, + text=text, + execute=False, + short_message_limit=None, + ) + + assert accepted["ok"] is True + assert accepted["status"] == "preview_ready" + + def test_unverified_reply_preserves_processing_reaction(tmp_path: Path) -> None: config, inbox, project = _fixture(tmp_path) record_lark_inbox_reaction(