feat(chat): harden multi-IM shortcuts and pagination
This commit is contained in:
@@ -10,14 +10,16 @@ chat_skill="skills/multi/dingtalk-chat/SKILL.md"
|
||||
event_skill="skills/multi/dingtalk-misc/references/event.md"
|
||||
mono_skill="skills/mono/SKILL.md"
|
||||
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
|
||||
chat_max_bytes=10000
|
||||
chat_target_bytes=10000
|
||||
chat_max_overage_percent=5
|
||||
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
|
||||
event_max_bytes=10000
|
||||
runtime_contract_max_bytes=3000
|
||||
|
||||
chat_bytes="$(wc -c < "$chat_skill" | tr -d ' ')"
|
||||
if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
|
||||
printf '%s\n' \
|
||||
"skill context budget exceeded: $chat_skill is ${chat_bytes} bytes (max ${chat_max_bytes})" >&2
|
||||
"skill context budget exceeded: $chat_skill is ${chat_bytes} bytes (target ${chat_target_bytes}, max ${chat_max_bytes} with ${chat_max_overage_percent}% allowance)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -106,4 +108,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
|
||||
fi
|
||||
|
||||
printf '%s\n' \
|
||||
"skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
|
||||
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.search.cross-conversation",
|
||||
"id": "chat.search.filtered",
|
||||
"preferred_tool": "chat.shortcut_search_msg",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
|
||||
@@ -234,6 +234,61 @@ def lower_count(tool: str, raw: Any) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def distinct_lower_message_count(
|
||||
raw_calls: list[tuple[str, str, Any]], tool: str
|
||||
) -> int | None:
|
||||
"""Count unique messages across all pages without retaining their IDs.
|
||||
|
||||
Time-boundary pagination may repeat the boundary message on the following
|
||||
page. Summing page lengths would therefore report a false projection
|
||||
mismatch for an otherwise correct de-duplicating Shortcut.
|
||||
"""
|
||||
return distinct_lower_collection_count(
|
||||
raw_calls,
|
||||
tool,
|
||||
("openMessageId", "openMsgId", "messageId", "msgId", "id"),
|
||||
)
|
||||
|
||||
|
||||
def distinct_lower_collection_count(
|
||||
raw_calls: list[tuple[str, str, Any]],
|
||||
tool: str,
|
||||
identity_keys: tuple[str, ...],
|
||||
) -> int | None:
|
||||
"""Count unique rows across every lower page for one tool."""
|
||||
seen: set[str] = set()
|
||||
anonymous = 0
|
||||
found_collection = False
|
||||
for _, call_tool, raw in raw_calls:
|
||||
if call_tool != tool:
|
||||
continue
|
||||
rows: list[Any] | None = None
|
||||
for path in LIST_PATHS_BY_TOOL.get(tool, ()):
|
||||
rows = at_path(raw, path)
|
||||
if rows is not None:
|
||||
break
|
||||
if rows is None:
|
||||
continue
|
||||
found_collection = True
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
anonymous += 1
|
||||
continue
|
||||
identity = ""
|
||||
for key in identity_keys:
|
||||
value = row.get(key)
|
||||
if value not in (None, ""):
|
||||
identity = str(value)
|
||||
break
|
||||
if identity:
|
||||
seen.add(identity)
|
||||
else:
|
||||
anonymous += 1
|
||||
if not found_collection:
|
||||
return None
|
||||
return len(seen) + anonymous
|
||||
|
||||
|
||||
def response_is_error(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
@@ -384,6 +439,52 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
|
||||
upper_count = count_projection_items(capture.stdout)
|
||||
expected_lower_count = lower_count(tool, raw) if tool else None
|
||||
pagination_raw = raw
|
||||
lower_message_projection = raw
|
||||
if capture.command == "+thread-replies" and tool == "list_topic_replies":
|
||||
expected_lower_count = distinct_lower_message_count(
|
||||
capture.raw_calls, "list_topic_replies"
|
||||
)
|
||||
lower_message_projection = [
|
||||
call_raw
|
||||
for _, call_tool, call_raw in capture.raw_calls
|
||||
if call_tool == "list_topic_replies"
|
||||
]
|
||||
elif capture.command == "+at-me" and tool == "search_at_me_message":
|
||||
expected_lower_count = distinct_lower_message_count(
|
||||
capture.raw_calls, "search_at_me_message"
|
||||
)
|
||||
lower_message_projection = [
|
||||
call_raw
|
||||
for _, call_tool, call_raw in capture.raw_calls
|
||||
if call_tool == "search_at_me_message"
|
||||
]
|
||||
elif (
|
||||
capture.command == "+messages-list-direct"
|
||||
and tool == "list_individual_chat_message"
|
||||
):
|
||||
expected_lower_count = distinct_lower_message_count(
|
||||
capture.raw_calls, "list_individual_chat_message"
|
||||
)
|
||||
lower_message_projection = [
|
||||
call_raw
|
||||
for _, call_tool, call_raw in capture.raw_calls
|
||||
if call_tool == "list_individual_chat_message"
|
||||
]
|
||||
elif capture.command in {"+chat-list-all", "+my-groups"} and tool == (
|
||||
"list_my_groups_pagination"
|
||||
):
|
||||
expected_lower_count = distinct_lower_collection_count(
|
||||
capture.raw_calls,
|
||||
"list_my_groups_pagination",
|
||||
(
|
||||
"openConversationId",
|
||||
"openconversation_id",
|
||||
"conversationId",
|
||||
"openCid",
|
||||
"cid",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
if capture.command == "+chat-members-list":
|
||||
counts = upper.get("counts", {}) if isinstance(upper, dict) else {}
|
||||
upper_count = counts.get("total") if isinstance(counts, dict) else None
|
||||
@@ -412,12 +513,23 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
|
||||
exact_passthrough = bool(capture.raw_calls and upper == raw)
|
||||
upper_message_projection = primary_projection_for_message_metrics(upper)
|
||||
upper_reactions = reaction_message_count(upper_message_projection, upper=True)
|
||||
lower_reactions = reaction_message_count(raw, upper=False)
|
||||
lower_reactions = reaction_message_count(lower_message_projection, upper=False)
|
||||
upper_threads = thread_message_count(upper_message_projection, upper=True)
|
||||
lower_threads = thread_message_count(raw, upper=False)
|
||||
lower_threads = thread_message_count(lower_message_projection, upper=False)
|
||||
upper_pagination = pagination_meta(upper)
|
||||
lower_pagination = pagination_meta(pagination_raw)
|
||||
|
||||
upper_explicitly_incomplete = (
|
||||
isinstance(upper, dict)
|
||||
and upper.get("complete") is False
|
||||
and (
|
||||
upper.get("hasMore") is True
|
||||
or bool(upper.get("failures"))
|
||||
or bool(upper.get("truncatedByPageLimit"))
|
||||
or bool(upper.get("truncatedByResultLimit"))
|
||||
)
|
||||
)
|
||||
|
||||
if capture.timed_out:
|
||||
status = "timeout"
|
||||
elif capture.exit_code != 0:
|
||||
@@ -426,6 +538,16 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
|
||||
status = "error_envelope"
|
||||
elif not capture.raw_calls:
|
||||
status = "no_lower_capture"
|
||||
elif upper_explicitly_incomplete:
|
||||
status = "incomplete"
|
||||
elif upper_count == 0 or (
|
||||
expected_lower_count == 0 and upper_count is None
|
||||
):
|
||||
# An empty collection is useful evidence, but it is not a successful
|
||||
# non-empty business-data E2E. This must precede exact-passthrough so a
|
||||
# raw empty list cannot be promoted to pass merely because projection
|
||||
# preserved it byte-for-byte.
|
||||
status = "pass_empty"
|
||||
elif exact_passthrough:
|
||||
status = "pass"
|
||||
elif expected_lower_count is not None and upper_count != expected_lower_count:
|
||||
@@ -439,8 +561,6 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
|
||||
or not upper_pagination.get("resume_present")
|
||||
):
|
||||
status = "pagination_mismatch"
|
||||
elif expected_lower_count == 0 and upper_count == 0:
|
||||
status = "pass_empty"
|
||||
else:
|
||||
status = "pass"
|
||||
|
||||
@@ -571,6 +691,7 @@ def live_audit(
|
||||
open_task_override = os.environ.get("DWS_AUDIT_OPEN_TASK_ID", "").strip()
|
||||
topic_group_override = os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip()
|
||||
topic_id_override = os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip()
|
||||
topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip()
|
||||
media_group_override = os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip()
|
||||
media_message_override = os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip()
|
||||
media_resource_override = os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip()
|
||||
@@ -592,17 +713,19 @@ def live_audit(
|
||||
return capture
|
||||
|
||||
category = run("+category-list")
|
||||
groups_all = run("+chat-list-all", "--limit", "200")
|
||||
groups_all = run(
|
||||
"+chat-list-all", "--limit", "200", "--page-all", "--page-limit", "50"
|
||||
)
|
||||
groups_mine = run("+chat-list-mine", "--limit", "200")
|
||||
run("+conversation-list", "--limit", "100")
|
||||
run("+conversation-list-top", "--limit", "100")
|
||||
run("+messages-list-unread-conversations", "--count", "100")
|
||||
run("+unread-chats", "--count", "100")
|
||||
run("+chat-list-join-requests", "--limit", "50")
|
||||
run("+at-me", "--days", "30")
|
||||
run("+at-me", "--days", "30", "--page-all", "--page-limit", "50")
|
||||
run("+bot-search", "--page", "1", "--size", "100")
|
||||
run("+bot-find", "--query", "机器人", "--limit", "20")
|
||||
run("+my-groups")
|
||||
run("+my-groups", "--page-all", "--page-limit", "50")
|
||||
|
||||
category_id = first_string(category.upper(), "categoryId", "category_id")
|
||||
if category_id:
|
||||
@@ -815,6 +938,9 @@ def live_audit(
|
||||
"--forward=false",
|
||||
"--limit",
|
||||
"20",
|
||||
"--page-all",
|
||||
"--page-limit",
|
||||
"50",
|
||||
],
|
||||
timeout,
|
||||
)
|
||||
@@ -904,17 +1030,32 @@ def live_audit(
|
||||
"topicId",
|
||||
)
|
||||
if topic_id:
|
||||
run(
|
||||
"+thread-replies",
|
||||
topic_args = [
|
||||
"--group",
|
||||
topic_group_override or group_id,
|
||||
"--topic-id",
|
||||
topic_id,
|
||||
"--time",
|
||||
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"--limit",
|
||||
"20",
|
||||
)
|
||||
]
|
||||
if topic_page_size:
|
||||
topic_args.extend(
|
||||
[
|
||||
"--limit",
|
||||
topic_page_size,
|
||||
"--page-all",
|
||||
"--page-limit",
|
||||
"50",
|
||||
]
|
||||
)
|
||||
else:
|
||||
topic_args.extend(
|
||||
[
|
||||
"--time",
|
||||
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"--limit",
|
||||
"20",
|
||||
]
|
||||
)
|
||||
run("+thread-replies", *topic_args)
|
||||
else:
|
||||
results["+thread-replies"] = blocked(
|
||||
"+thread-replies",
|
||||
@@ -1129,6 +1270,11 @@ def main() -> int:
|
||||
]
|
||||
if any(topic_override) and not all(topic_override):
|
||||
parser.error("topic fixture override requires both group and topic IDs")
|
||||
topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip()
|
||||
if topic_page_size and (
|
||||
not topic_page_size.isdigit() or int(topic_page_size) < 1
|
||||
):
|
||||
parser.error("DWS_AUDIT_TOPIC_PAGE_SIZE must be a positive integer")
|
||||
media_override = [
|
||||
os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip(),
|
||||
os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip(),
|
||||
@@ -1172,6 +1318,7 @@ def main() -> int:
|
||||
"timeout",
|
||||
"no_lower_capture",
|
||||
"local_artifact_mismatch",
|
||||
"incomplete",
|
||||
}
|
||||
return 1 if any(result["status"] in failures for result in results) else 0
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from run_chat_shortcut_live_audit import (
|
||||
Capture,
|
||||
distinct_lower_collection_count,
|
||||
distinct_lower_message_count,
|
||||
primary_projection_for_message_metrics,
|
||||
reaction_message_count,
|
||||
summarize_capture,
|
||||
thread_message_count,
|
||||
)
|
||||
|
||||
@@ -41,11 +45,206 @@ def test_non_envelope_passthrough() -> None:
|
||||
check("bare list passes through", primary_projection_for_message_metrics(rows) is rows)
|
||||
|
||||
|
||||
def test_thread_reply_pages_are_deduplicated() -> None:
|
||||
raw_calls = [
|
||||
(
|
||||
"chat",
|
||||
"list_topic_replies",
|
||||
{
|
||||
"result": {
|
||||
"hasMore": True,
|
||||
"messages": [
|
||||
{"openMessageId": "m1", "createTime": "2026-08-05 16:48:20"}
|
||||
],
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"chat",
|
||||
"list_topic_replies",
|
||||
{
|
||||
"result": {
|
||||
"hasMore": False,
|
||||
"messages": [
|
||||
{"openMessageId": "m1", "createTime": "2026-08-05 16:48:20"},
|
||||
{"openMessageId": "m0", "createTime": "2026-08-05 16:47:20"},
|
||||
],
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
check(
|
||||
"boundary duplicate counted once",
|
||||
distinct_lower_message_count(raw_calls, "list_topic_replies") == 2,
|
||||
)
|
||||
|
||||
capture = Capture(
|
||||
command="+thread-replies",
|
||||
argv=[],
|
||||
exit_code=0,
|
||||
stdout=(
|
||||
'{"replies":[{"messageId":"m1"},{"messageId":"m0"}],'
|
||||
'"count":2,"complete":true,"hasMore":false,"pagesFetched":2}'
|
||||
),
|
||||
stderr="",
|
||||
raw_calls=raw_calls,
|
||||
duration_ms=1,
|
||||
)
|
||||
summary = summarize_capture(capture)
|
||||
check("multi-page projection passes", summary["status"] == "pass")
|
||||
check("all lower calls reported", summary["lower"]["call_count"] == 2)
|
||||
check("unique lower count reported", summary["lower"]["count"] == 2)
|
||||
|
||||
|
||||
def test_group_pages_are_counted_across_all_lower_calls() -> None:
|
||||
raw_calls = [
|
||||
(
|
||||
"im",
|
||||
"list_my_groups_pagination",
|
||||
{"result": {"groups": [{"openConversationId": "g1"}], "hasMore": True}},
|
||||
),
|
||||
(
|
||||
"im",
|
||||
"list_my_groups_pagination",
|
||||
{
|
||||
"result": {
|
||||
"groups": [
|
||||
{"openConversationId": "g1"},
|
||||
{"openConversationId": "g2"},
|
||||
],
|
||||
"hasMore": False,
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
check(
|
||||
"group pages deduplicated",
|
||||
distinct_lower_collection_count(
|
||||
raw_calls,
|
||||
"list_my_groups_pagination",
|
||||
("openConversationId", "conversationId", "id"),
|
||||
)
|
||||
== 2,
|
||||
)
|
||||
capture = Capture(
|
||||
command="+chat-list-all",
|
||||
argv=[],
|
||||
exit_code=0,
|
||||
stdout=(
|
||||
'{"groups":[{"openConversationId":"g1"},'
|
||||
'{"openConversationId":"g2"}],"count":2,'
|
||||
'"complete":true,"hasMore":false,"pagesFetched":2}'
|
||||
),
|
||||
stderr="",
|
||||
raw_calls=raw_calls,
|
||||
duration_ms=1,
|
||||
)
|
||||
summary = summarize_capture(capture)
|
||||
check("multi-page group projection passes", summary["status"] == "pass")
|
||||
check("all group rows reported", summary["lower"]["count"] == 2)
|
||||
|
||||
|
||||
def test_thread_reply_empty_terminal_page_passes() -> None:
|
||||
raw_calls = [
|
||||
(
|
||||
"chat",
|
||||
"list_topic_replies",
|
||||
{
|
||||
"result": {
|
||||
"hasMore": True,
|
||||
"messages": [
|
||||
{"openMessageId": "m1", "createTime": "2026-08-05 16:48:20"}
|
||||
],
|
||||
}
|
||||
},
|
||||
),
|
||||
("chat", "list_topic_replies", {"result": {"hasMore": False, "messages": []}}),
|
||||
]
|
||||
capture = Capture(
|
||||
command="+thread-replies",
|
||||
argv=[],
|
||||
exit_code=0,
|
||||
stdout=(
|
||||
'{"replies":[{"messageId":"m1"}],"count":1,'
|
||||
'"complete":true,"hasMore":false,"pagesFetched":2}'
|
||||
),
|
||||
stderr="",
|
||||
raw_calls=raw_calls,
|
||||
duration_ms=1,
|
||||
)
|
||||
summary = summarize_capture(capture)
|
||||
check("empty terminal page passes", summary["status"] == "pass")
|
||||
check("first-page item retained", summary["lower"]["count"] == 1)
|
||||
|
||||
|
||||
def test_empty_projection_is_not_promoted_to_non_empty_pass() -> None:
|
||||
capture = Capture(
|
||||
command="+bot-search",
|
||||
argv=[],
|
||||
exit_code=0,
|
||||
stdout='{"bots":[],"count":0}',
|
||||
stderr="",
|
||||
raw_calls=[("bot", "search_my_robots", {"result": {"robots": []}})],
|
||||
duration_ms=1,
|
||||
)
|
||||
summary = summarize_capture(capture)
|
||||
check("empty projected collection is explicit", summary["status"] == "pass_empty")
|
||||
|
||||
no_upper_count = Capture(
|
||||
command="+unread-chats",
|
||||
argv=[],
|
||||
exit_code=0,
|
||||
stdout='{"result":[]}',
|
||||
stderr="",
|
||||
raw_calls=[
|
||||
(
|
||||
"chat",
|
||||
"unread_message_conversation_list",
|
||||
{"result": {"conversations": []}},
|
||||
)
|
||||
],
|
||||
duration_ms=1,
|
||||
)
|
||||
summary = summarize_capture(no_upper_count)
|
||||
check(
|
||||
"lower-confirmed empty collection without upper count is explicit",
|
||||
summary["status"] == "pass_empty",
|
||||
)
|
||||
|
||||
|
||||
def test_incomplete_projection_is_not_promoted_to_pass() -> None:
|
||||
capture = Capture(
|
||||
command="+thread-replies",
|
||||
argv=[],
|
||||
exit_code=0,
|
||||
stdout=(
|
||||
'{"replies":[{"messageId":"m1"}],"count":1,'
|
||||
'"complete":false,"hasMore":true,"truncatedByPageLimit":true,'
|
||||
'"pagesFetched":1,"nextPage":{"time":"2026-08-05T08:48:19.136Z"}}'
|
||||
),
|
||||
stderr="",
|
||||
raw_calls=[(
|
||||
"chat",
|
||||
"list_topic_replies",
|
||||
{"result": {"hasMore": True, "nextCursor": 1785919699136,
|
||||
"messages": [{"openMessageId": "m1"}]}},
|
||||
)],
|
||||
duration_ms=1,
|
||||
)
|
||||
summary = summarize_capture(capture)
|
||||
check("bounded partial read is explicit", summary["status"] == "incomplete")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tests = [
|
||||
test_compatibility_aliases_are_counted_once,
|
||||
test_projection_fallback_order,
|
||||
test_non_envelope_passthrough,
|
||||
test_thread_reply_pages_are_deduplicated,
|
||||
test_group_pages_are_counted_across_all_lower_calls,
|
||||
test_thread_reply_empty_terminal_page_passes,
|
||||
test_empty_projection_is_not_promoted_to_non_empty_pass,
|
||||
test_incomplete_projection_is_not_promoted_to_pass,
|
||||
]
|
||||
for test in tests:
|
||||
print(f"{test.__name__}:")
|
||||
|
||||
Reference in New Issue
Block a user