Several read shortcuts returned an empty list with exit 0 and no error envelope even though the underlying MCP tool returned data, so agents misread "no data" and made wrong decisions. Root causes: - Container key mismatch: the resolver probed the wrong key — processCodeList / values / wikiSpaces / itemList / groupList / recentItems / emailAccounts / deptUserList / labelUserList / roles / report_list, plus get_org_labels grouped labels[] needing a descend. - Item fields nested under a VO wrapper, not unwrapped: shiftVO / entityVO / userInfo. - Param exceeded a backend limit: todo +created-todos sent pageSize=50 while the backend silently returns empty for pageSize>20; now uses the shared pager (pageSize=20). Affected: contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart resolvers. Every fix ships a guard test that feeds the real backend response shape (and, for minutes, both the taskUuid and minutesId item shapes) and asserts the projection is non-empty with a usable id. scripts/shortcut_real_result.py now compares the upper (projection) output against the lower (raw backend) layer, and record_real_shortcut_run.py captures the lower layer in memory (persisting only derived counts, never raw PII) so an exit-0 empty projection over a non-empty backend is scored as projection-data-loss instead of real-ok. The Python self-test runs in CI via test/scripts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
8.1 KiB
Python
205 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Self-checking tests for scripts/shortcut_real_result.py.
|
|
|
|
Run: python3 scripts/shortcut_real_result_test.py
|
|
|
|
Focus: the upper/lower layer comparison rules added after the
|
|
contact +list-roles / oa +list-forms projection-data-loss case, plus a
|
|
regression guard that pre-existing exit-code / error-envelope behaviour is
|
|
unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from shortcut_real_result import (
|
|
backend_record_count,
|
|
classify_failure,
|
|
classify_real_status,
|
|
compare_layers,
|
|
count_projection_items,
|
|
payload_indicates_error,
|
|
projection_audit,
|
|
)
|
|
|
|
# Faithful backend shapes from the real case.
|
|
GROUPED_ROLES = {
|
|
"result": [
|
|
{"groupName": "默认", "labels": [{"labelId": 1, "name": "主管理员"}, {"labelId": 2, "name": "负责人"}]},
|
|
{"groupName": "职务", "labels": [{"labelId": 3, "name": "财务"}]},
|
|
]
|
|
}
|
|
PROCESS_CODE_LIST = {
|
|
"result": {
|
|
"processCodeList": [
|
|
{"processCode": "PROC-1", "processName": "请假"},
|
|
{"processCode": "PROC-2", "processName": "加班"},
|
|
],
|
|
"totalCount": -1,
|
|
},
|
|
"success": True,
|
|
}
|
|
|
|
EMPTY_ROLES_PROJECTION = json.dumps({"count": 0, "roles": []})
|
|
FULL_ROLES_PROJECTION = json.dumps({"count": 2, "roles": [{"labelId": 1}, {"labelId": 2}]})
|
|
DETAIL_PROJECTION = json.dumps({"userId": "u1", "name": "张三"})
|
|
|
|
|
|
def check(name, cond):
|
|
if not cond:
|
|
raise AssertionError(f"FAIL: {name}")
|
|
print(f" ok: {name}")
|
|
|
|
|
|
def test_count_projection_items():
|
|
check("empty roles -> 0", count_projection_items(EMPTY_ROLES_PROJECTION) == 0)
|
|
check("full roles -> 2", count_projection_items(FULL_ROLES_PROJECTION) == 2)
|
|
check("detail (no list) -> None", count_projection_items(DETAIL_PROJECTION) is None)
|
|
check("bare array -> len", count_projection_items("[1,2,3]") == 3)
|
|
check("empty string -> None", count_projection_items("") is None)
|
|
|
|
|
|
def test_backend_record_count():
|
|
# backend_record_count is a "has data" proxy = size of the largest single
|
|
# object array found at any depth (not the flattened total).
|
|
check("grouped roles has data (>0)", backend_record_count(GROUPED_ROLES) == 2)
|
|
check("processCodeList count", backend_record_count(PROCESS_CODE_LIST) == 2)
|
|
check("empty backend -> 0", backend_record_count({"result": []}) == 0)
|
|
check("error envelope -> 0", backend_record_count({"errorCode": 1, "errorMessage": "x"}) == 0)
|
|
|
|
|
|
def test_compare_layers():
|
|
lost, upper, lower = compare_layers(EMPTY_ROLES_PROJECTION, GROUPED_ROLES)
|
|
check("roles: data loss detected", lost and upper == 0 and lower == 2)
|
|
lost2, _, _ = compare_layers(FULL_ROLES_PROJECTION, GROUPED_ROLES)
|
|
check("roles: no loss when upper populated", not lost2)
|
|
lost3, _, lower3 = compare_layers(EMPTY_ROLES_PROJECTION, {"result": []})
|
|
check("roles: no loss when backend also empty", not lost3 and lower3 == 0)
|
|
|
|
|
|
def test_classify_real_status():
|
|
# Backward-compat: without backend_raw, empty projection is still real-ok.
|
|
check(
|
|
"legacy empty projection stays real-ok",
|
|
classify_real_status(0, EMPTY_ROLES_PROJECTION) == "real-ok",
|
|
)
|
|
# With lower layer that has data, empty projection becomes real-error.
|
|
check(
|
|
"empty projection + backend data -> real-error",
|
|
classify_real_status(0, EMPTY_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-error",
|
|
)
|
|
# Populated projection stays ok even with backend.
|
|
check(
|
|
"full projection + backend -> real-ok",
|
|
classify_real_status(0, FULL_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-ok",
|
|
)
|
|
# Regression: non-zero exit and error envelope untouched.
|
|
check("nonzero exit -> real-error", classify_real_status(5, "") == "real-error")
|
|
check(
|
|
"error envelope -> real-error",
|
|
classify_real_status(0, json.dumps({"success": False, "error": "boom"})) == "real-error",
|
|
)
|
|
check("timeout preserved", classify_real_status(0, "", "timeout") == "timeout")
|
|
|
|
|
|
def test_classify_failure_projection_loss():
|
|
result = {
|
|
"status": "real-ok", # process exited 0, looked fine
|
|
"exit_code": 0,
|
|
"service": "contact",
|
|
"command": "+list-roles",
|
|
"stdout": EMPTY_ROLES_PROJECTION,
|
|
"backend_stdout": json.dumps(GROUPED_ROLES),
|
|
}
|
|
category, fixability, _ = classify_failure(result)
|
|
check("projection-data-loss category", category == "projection-data-loss")
|
|
check("cli fixable", fixability == "cli-projection-fix-needed")
|
|
|
|
# No loss when projection is populated.
|
|
ok_result = dict(result, stdout=FULL_ROLES_PROJECTION)
|
|
check("populated -> passed", classify_failure(ok_result)[0] == "passed")
|
|
|
|
# Regression: a normal real-ok with no backend capture still passes.
|
|
check(
|
|
"plain real-ok passes",
|
|
classify_failure({"status": "real-ok", "exit_code": 0, "stdout": FULL_ROLES_PROJECTION})[0] == "passed",
|
|
)
|
|
|
|
|
|
def test_projection_audit():
|
|
# Empty projection, no backend -> must-verify warning.
|
|
warn = projection_audit({"stdout": EMPTY_ROLES_PROJECTION})
|
|
check("unverified empty flagged", warn is not None and warn["kind"] == "empty-projection-unverified")
|
|
|
|
# Empty projection + backend data -> data loss.
|
|
warn2 = projection_audit({"stdout": EMPTY_ROLES_PROJECTION, "backend": GROUPED_ROLES})
|
|
check("data loss flagged", warn2 is not None and warn2["kind"] == "projection-data-loss")
|
|
|
|
# Populated projection -> no warning.
|
|
check("populated -> no warning", projection_audit({"stdout": FULL_ROLES_PROJECTION}) is None)
|
|
|
|
# Detail (non-list) projection -> no warning.
|
|
check("detail -> no warning", projection_audit({"stdout": DETAIL_PROJECTION}) is None)
|
|
|
|
|
|
def test_payload_indicates_error_regression():
|
|
check("clean payload not error", not payload_indicates_error(FULL_ROLES_PROJECTION))
|
|
check("errorCode!=0 is error", payload_indicates_error(json.dumps({"errorCode": 1})))
|
|
check("errorCode==0 not error", not payload_indicates_error(json.dumps({"errorCode": 0})))
|
|
|
|
|
|
def test_record_run_wires_lower_layer():
|
|
"""End-to-end: record_real_shortcut_run.py must capture the lower layer and
|
|
flag an exit-0 empty projection as projection-data-loss (not real-ok)."""
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
recorder = os.path.join(here, "record_real_shortcut_run.py")
|
|
with tempfile.TemporaryDirectory() as d:
|
|
backend = os.path.join(d, "backend.json")
|
|
out = os.path.join(d, "out.json")
|
|
# Lower layer has 3 roles nested under result[].labels[].
|
|
with open(backend, "w") as f:
|
|
json.dump(GROUPED_ROLES, f)
|
|
env = dict(os.environ, SHORTCUT_REAL_RESULTS_PATH=out)
|
|
# Upper command: an empty projection (exit 0, no error envelope).
|
|
argv = ["python3", "-c", "print('{\"count\": 0, \"roles\": []}')"]
|
|
p = subprocess.run(
|
|
["python3", recorder, "--service", "contact", "--command", "+list-roles",
|
|
"--risk", "read", "--backend-raw", backend, "--"] + argv,
|
|
capture_output=True, text=True, env=env,
|
|
)
|
|
check("recorder exits non-zero on data loss", p.returncode == 2)
|
|
rec = json.loads(p.stdout)
|
|
check("status flipped to real-error", rec["status"] == "real-error")
|
|
check("category is projection-data-loss", rec.get("failure_category") == "projection-data-loss")
|
|
check("projection_audit attached", rec.get("projection_audit", {}).get("kind") == "projection-data-loss")
|
|
# Raw backend payload must NOT be persisted.
|
|
persisted = json.load(open(out))
|
|
blob = json.dumps(persisted, ensure_ascii=False)
|
|
check("raw lower-layer not persisted", "主管理员" not in blob and "labels" not in blob)
|
|
|
|
|
|
def main():
|
|
tests = [
|
|
test_count_projection_items,
|
|
test_backend_record_count,
|
|
test_compare_layers,
|
|
test_classify_real_status,
|
|
test_classify_failure_projection_loss,
|
|
test_projection_audit,
|
|
test_payload_indicates_error_regression,
|
|
test_record_run_wires_lower_layer,
|
|
]
|
|
for t in tests:
|
|
print(f"{t.__name__}:")
|
|
t()
|
|
print(f"\nAll {len(tests)} test groups passed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|