From 830e240ea970da8ca7680c44ae269ecc14688152 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:47:54 +0800 Subject: [PATCH 1/2] fix(lark): bound an answer delivery by the provider, not by the notice length The 1200-character limit is a compact shape for a chat-root notification, not a provider bound; the provider accepts 150 KB in one text message. Every delivery that carried no source message inherited it as if it were a transport limit, so an ordinary long answer was cut and handed to the bounded part sequence even though one message would have fit. Make the limit an explicit parameter: the notification caller owns it, and the answer deliveries (the manager answer, the manager return and the part fallback) declare the provider bound instead. The part sequence stays as the fallback for a body the provider itself cannot take. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/extensions/lark/goal_topic_runtime.py | 5 ++++ loopx/extensions/lark/inbox_reply.py | 31 +++++++++++++++++--- loopx/extensions/lark/manager_reply_parts.py | 3 ++ loopx/extensions/lark/manager_returns.py | 3 ++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 30f9c47654..9fb3c7ad1a 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 e0737dc7ed..2d635c4b65 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 6be3ead7d7..2d25a3a2f9 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 cb0508083b..5721a277dc 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 {}), ) From 5855bb44b31d1636ccfd161155c04a5d6d91edef Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:47:55 +0800 Subject: [PATCH 2/2] test(lark): pin one-message answer delivery and the notice-only length One route-level case drives a 3000-character steward answer through process_lark_goal_topic_event and asserts exactly one reply carrying the whole body with no part record, and one focused case asserts the same over-limit text is still rejected as a chat-root notification while the answer bound accepts it. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../test_lark_goal_topic_runtime.py | 86 +++++++++++++++++++ tests/extensions/test_lark_inbox_reactions.py | 36 ++++++++ 2 files changed, 122 insertions(+) diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index 0de07b6df9..aa33097790 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 1d448fee0f..62e7b63fe0 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(