Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions loopx/extensions/lark/goal_topic_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 27 additions & 4 deletions loopx/extensions/lark/inbox_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions loopx/extensions/lark/manager_reply_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions loopx/extensions/lark/manager_returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}),
)

Expand Down
86 changes: 86 additions & 0 deletions tests/extensions/test_lark_goal_topic_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 36 additions & 0 deletions tests/extensions/test_lark_inbox_reactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 + '<at open_id="ou_public_reviewer">Public Reviewer</at> 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(
Expand Down
Loading