fix(shortcut): fix projection-data-loss silent-empty returns

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>
This commit is contained in:
DennisandClaude Opus 4.8 committed 2026-07-24 10:07:02 +08:00
1 parent 9f76c1844a
commit b1e7b43f85
28 files changed
+1418 -45

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
### Fixed ### Fixed
- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`.
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag". - **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
## [1.0.54] - 2026-07-21 ## [1.0.54] - 2026-07-21
+15 -1
View File
@@ -455,6 +455,12 @@ func searchClassProject(data map[string]any) []map[string]any {
if !ok { if !ok {
continue continue
} }
// get_class_list wraps each shift's identity/label under shiftVO
// (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every
// row projects empty and the whole command silently returns nothing.
if vo, ok := m["shiftVO"].(map[string]any); ok {
m = vo
}
row := map[string]any{} row := map[string]any{}
if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok { if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok {
row["classId"] = v row["classId"] = v
@@ -668,13 +674,21 @@ var SearchAdjustmentRule = shortcut.Shortcut{
// across candidate keys, so an unknown/empty shape yields an empty list rather // across candidate keys, so an unknown/empty shape yields an empty list rather
// than a crash or fabricated data. // than a crash or fabricated data.
func searchRuleProject(data map[string]any) []map[string]any { func searchRuleProject(data map[string]any) []map[string]any {
raw := attendanceResolveList(data, "ruleList", "rules", "list", "items") // get_overtime_rule nests the list under result.atRuleList and
// get_adjustment_rule under result.adjustmentList; both wrap each rule's
// identity/label under entityVO. Probe those container keys AND unwrap
// entityVO, otherwise +search-overtime-rule / +search-adjustment-rule
// silently return empty despite the backend returning rules.
raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items")
out := make([]map[string]any, 0, len(raw)) out := make([]map[string]any, 0, len(raw))
for _, item := range raw { for _, item := range raw {
m, ok := item.(map[string]any) m, ok := item.(map[string]any)
if !ok { if !ok {
continue continue
} }
if vo, ok := m["entityVO"].(map[string]any); ok {
m = vo
}
row := map[string]any{} row := map[string]any{}
if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok { if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok {
row["ruleId"] = v row["ruleId"] = v
@@ -0,0 +1,70 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package attendance
import (
"encoding/json"
"testing"
)
// TestSearchClassProjectShiftVOShape guards against projection-data-loss:
// get_class_list nests the list under result.items and wraps each shift's
// identity under shiftVO. The projection must unwrap shiftVO or +search-class
// silently returns empty despite the backend returning shifts.
func TestSearchClassProjectShiftVOShape(t *testing.T) {
const raw = `{"result":{"items":[
{"shiftVO":{"id":957395083,"name":"默认班次"}},
{"shiftVO":{"id":957395084,"name":"早班"}}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := searchClassProject(data)
if len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 2 个班次,投影返回 %d 个 (%v)", len(got), got)
}
if got[0]["name"] == nil || got[0]["classId"] == nil {
t.Fatalf("shiftVO fields not unwrapped: %v", got[0])
}
}
// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
// and get_adjustment_rule (result.adjustmentList), both wrapping the rule under
// entityVO. The resolver must probe those keys and unwrap entityVO or
// +search-overtime-rule / +search-adjustment-rule silently return empty.
func TestSearchRuleProjectEntityVOShape(t *testing.T) {
for _, tc := range []struct {
name, key string
}{
{"overtime", "atRuleList"},
{"adjustment", "adjustmentList"},
} {
raw := `{"result":{"` + tc.key + `":[
{"entityVO":{"id":11,"name":"工作日加班"},"permissionVO":{}},
{"entityVO":{"id":12,"name":"节假日加班"},"permissionVO":{}}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err)
}
got := searchRuleProject(data)
if len(got) != 2 {
t.Fatalf("[%s] 上下层数据不一致: 底层 result.%s 有 2 条,投影返回 %d 条", tc.name, tc.key, len(got))
}
if got[0]["ruleId"] == nil || got[0]["name"] == nil {
t.Fatalf("[%s] entityVO fields not unwrapped: %v", tc.name, got[0])
}
}
}
+4 -1
View File
@@ -494,7 +494,10 @@ func roomGroupsProject(data map[string]any) []map[string]any {
// roomGroupsContainer locates the group slice across candidate wrapper keys, // roomGroupsContainer locates the group slice across candidate wrapper keys,
// unwrapping one nested object layer (e.g. result.list) when needed. // unwrapping one nested object layer (e.g. result.list) when needed.
func roomGroupsContainer(data map[string]any) []any { func roomGroupsContainer(data map[string]any) []any {
keys := []string{"result", "data", "list", "items", "groups"} // list_meeting_room_groups nests the groups under result.groupList;
// "groupList" MUST be probed or +room-groups silently returns empty despite
// the backend returning meeting-room groups.
keys := []string{"result", "data", "list", "items", "groupList", "groups"}
for _, k := range keys { for _, k := range keys {
v, ok := data[k] v, ok := data[k]
if !ok { if !ok {
@@ -0,0 +1,37 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package calendar
import (
"encoding/json"
"testing"
)
// TestRoomGroupsProjectGroupListShape guards against projection-data-loss:
// list_meeting_room_groups nests the groups under result.groupList; the resolver
// must probe "groupList" or +room-groups silently returns empty despite the
// backend returning meeting-room groups.
func TestRoomGroupsProjectGroupListShape(t *testing.T) {
const raw = `{"result":{"groupList":[
{"groupId":"g1","groupName":"北楼会议室"},
{"groupId":"g2","groupName":"南楼会议室"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := roomGroupsProject(data); len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 result.groupList 有 2 条,投影返回 %d 条", len(got))
}
}
+7 -2
View File
@@ -413,7 +413,12 @@ func chatGroupResolveList(data map[string]any) []any {
if data == nil { if data == nil {
return []any{} return []any{}
} }
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations"} { // "roles" is probed too: chatRoleListProject reuses this resolver and
// list_custom_group_roles nests its list under result.roles — without it
// +chat-role-list silently returns empty despite the group having roles.
// Group listings key on groups/conversations, which are probed first, so
// adding roles is harmless to them.
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations", "roles"} {
v, ok := data[key] v, ok := data[key]
if !ok { if !ok {
continue continue
@@ -422,7 +427,7 @@ func chatGroupResolveList(data map[string]any) []any {
return arr return arr
} }
if inner, ok := v.(map[string]any); ok { if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "groups", "conversations", "result", "data"} { for _, ik := range []string{"list", "items", "groups", "conversations", "roles", "result", "data"} {
if arr, ok := inner[ik].([]any); ok { if arr, ok := inner[ik].([]any); ok {
return arr return arr
} }
@@ -0,0 +1,54 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"encoding/json"
"testing"
)
// TestChatRoleListProjectRolesShape guards against projection-data-loss:
// list_custom_group_roles nests the roles under result.roles; the shared
// resolver must probe "roles" or +chat-role-list silently returns empty despite
// the group having custom roles.
func TestChatRoleListProjectRolesShape(t *testing.T) {
const raw = `{"result":{"roles":[
{"openRoleId":"r1","name":"CTO"},
{"openRoleId":"r2","name":"CEO"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := chatRoleListProject(data)
if len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 result.roles 有 2 条,投影返回 %d 条 (%v)", len(got), got)
}
if got[0]["openRoleId"] == nil || got[0]["name"] == nil {
t.Fatalf("role fields missing: %v", got[0])
}
}
// TestChatGroupResolveListStillHandlesGroups ensures adding "roles" did not
// disturb the group-listing shape the resolver is shared with.
func TestChatGroupResolveListStillHandlesGroups(t *testing.T) {
const raw = `{"result":{"groups":[{"openConversationId":"c1","title":"项目群"}]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := chatGroupResolveList(data); len(got) != 1 {
t.Fatalf("group shape regressed: want 1, got %d", len(got))
}
}
+51 -4
View File
@@ -214,13 +214,13 @@ func listRolesResolveList(data map[string]any) []any {
continue continue
} }
if arr, ok := v.([]any); ok { if arr, ok := v.([]any); ok {
return arr return listRolesFlattenGroups(arr)
} }
// container may itself wrap the list one level deeper // container may itself wrap the list one level deeper
if inner, ok := v.(map[string]any); ok { if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "labels", "result", "data"} { for _, ik := range []string{"list", "items", "labels", "result", "data"} {
if arr, ok := inner[ik].([]any); ok { if arr, ok := inner[ik].([]any); ok {
return arr return listRolesFlattenGroups(arr)
} }
} }
} }
@@ -228,6 +228,42 @@ func listRolesResolveList(data map[string]any) []any {
return []any{} return []any{}
} }
// listRolesFlattenGroups flattens the grouped get_org_labels response into a
// flat list of label objects. get_org_labels returns result[] as label GROUPS
// ({groupName, labels[]}), nesting the actual roles one level under each group's
// labels[]. A naive top-level scan would hand back the group wrappers — which
// carry no labelId/name — so every row would silently project to empty. When
// the array is already a flat label list (no per-group labels[]), it is returned
// unchanged so the projection tolerates both response shapes.
func listRolesFlattenGroups(arr []any) []any {
flattened := make([]any, 0, len(arr))
sawGroup := false
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
flattened = append(flattened, item)
continue
}
var nested []any
for _, gk := range []string{"labels", "subLabels"} {
if inner, ok := m[gk].([]any); ok {
nested = inner
break
}
}
if nested != nil {
sawGroup = true
flattened = append(flattened, nested...)
continue
}
flattened = append(flattened, item)
}
if sawGroup {
return flattened
}
return arr
}
// listRolesFirst returns the first present candidate key's value. // listRolesFirst returns the first present candidate key's value.
func listRolesFirst(m map[string]any, keys ...string) (any, bool) { func listRolesFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys { for _, k := range keys {
@@ -291,6 +327,12 @@ func memberListProject(data map[string]any) []map[string]any {
if !ok { if !ok {
continue continue
} }
// get_dept_members_by_deptId wraps each member under userInfo
// (deptUserList item = {"userInfo": {userId, name, ...}}); unwrap it or
// every row projects empty and +list-dept-members silently returns none.
if ui, ok := m["userInfo"].(map[string]any); ok {
m = ui
}
row := map[string]any{} row := map[string]any{}
if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil { if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil {
row["userId"] = v row["userId"] = v
@@ -306,9 +348,14 @@ func memberListProject(data map[string]any) []map[string]any {
} }
// memberListFindList returns the first slice found under the common list // memberListFindList returns the first slice found under the common list
// container keys, or nil when none is present. // container keys, or nil when none is present. get_dept_members_by_deptId nests
// its members under deptUserList, so that key must be probed too.
func memberListFindList(m map[string]any) []any { func memberListFindList(m map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "members", "userList"} { // get_dept_members_by_deptId nests under deptUserList and
// get_label_members_by_labelId under labelUserList; both must be probed (and
// each item unwrapped from userInfo above) or +list-dept-members /
// +list-role-members silently return empty despite having members.
for _, k := range []string{"result", "data", "list", "items", "members", "userList", "deptUserList", "labelUserList"} {
if arr, ok := m[k].([]any); ok { if arr, ok := m[k].([]any); ok {
return arr return arr
} }
@@ -0,0 +1,84 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package contact
import (
"encoding/json"
"testing"
)
// TestListRolesProjectGroupedShape guards against the projection-data-loss class:
// get_org_labels returns result[] as label GROUPS with the real roles nested one
// level under each group's labels[]. The projection MUST descend into those
// nested labels[] rather than treating group wrappers as roles — otherwise it
// silently returns empty (exit 0, no error envelope) while the backend has data.
func TestListRolesProjectGroupedShape(t *testing.T) {
// Faithful get_org_labels shape (as returned by the backend / leaf command).
const raw = `{"result":[
{"groupName":"默认","labels":[
{"labelId":101,"name":"角色A"},
{"labelId":102,"name":"角色B"},
{"labelId":103,"name":"角色C"}]},
{"groupName":"职务","labels":[
{"labelId":104,"name":"角色D"},
{"labelId":105,"name":"角色E"}]}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
roles := listRolesProject(data)
if len(roles) != 5 {
t.Fatalf("上下层数据不一致: 底层有 5 个角色,投影返回 %d 个 (roles=%v)", len(roles), roles)
}
for _, r := range roles {
if r["labelId"] == nil || r["labelName"] == nil {
t.Fatalf("projected role missing labelId/labelName: %v", r)
}
}
}
// TestListRolesProjectNestedWrapperAndNonMap covers the nested map-wrapper branch
// ({result:{labels:[...]}}) and a non-map array element, which must be passed
// through rather than dropped.
func TestListRolesProjectNestedWrapperAndNonMap(t *testing.T) {
const raw = `{"result":{"labels":[
{"groupName":"默认","labels":[{"labelId":1,"name":"角色A"}]},
"junk"
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if roles := listRolesProject(data); len(roles) != 1 {
t.Fatalf("nested wrapper + non-map: want 1 role, got %d (%v)", len(roles), roles)
}
}
// TestListRolesProjectFlatShape ensures the flatten step is a no-op when the
// response is already a flat label list (response-shape drift tolerance).
func TestListRolesProjectFlatShape(t *testing.T) {
const raw = `{"result":[
{"labelId":1,"name":"销售"},
{"labelId":2,"name":"市场"}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if roles := listRolesProject(data); len(roles) != 2 {
t.Fatalf("flat shape: want 2 roles, got %d (%v)", len(roles), roles)
}
}
@@ -0,0 +1,76 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package contact
import (
"encoding/json"
"testing"
)
// TestMemberListProjectDeptUserListShape guards against projection-data-loss:
// get_dept_members_by_deptId returns members under deptUserList, each wrapped in
// userInfo. The resolver must probe deptUserList AND unwrap userInfo or
// +list-dept-members silently returns empty despite the backend returning members.
func TestMemberListProjectDeptUserListShape(t *testing.T) {
const raw = `{"deptUserList":[
{"userInfo":{"userId":"u1","name":"张三"}},
{"userInfo":{"userId":"u2","name":"李四"}}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := memberListProject(data)
if len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 deptUserList 有 2 人,投影返回 %d 人 (%v)", len(got), got)
}
if got[0]["userId"] == nil || got[0]["name"] == nil {
t.Fatalf("userInfo fields not unwrapped: %v", got[0])
}
}
// TestMemberListProjectLabelUserListShape guards the role-member path:
// get_label_members_by_labelId nests members under labelUserList, each wrapped
// in userInfo. The resolver must probe labelUserList AND unwrap userInfo or
// +list-role-members silently returns empty despite the role having members.
func TestMemberListProjectLabelUserListShape(t *testing.T) {
const raw = `{"labelUserList":[
{"userInfo":{"userId":"u1","name":"王五"}},
{"userInfo":{"userId":"u2","name":"赵六"}}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := memberListProject(data)
if len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 labelUserList 有 2 人,投影返回 %d 人 (%v)", len(got), got)
}
if got[0]["userId"] == nil || got[0]["name"] == nil {
t.Fatalf("userInfo not unwrapped: %v", got[0])
}
}
// TestMemberListProjectFlatRoleShape ensures the shared projection still handles
// a flat member shape unchanged.
func TestMemberListProjectFlatRoleShape(t *testing.T) {
const raw = `{"result":[{"userId":"u9","name":"张三"}]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := memberListProject(data); len(got) != 1 {
t.Fatalf("flat role-member shape: want 1, got %d (%v)", len(got), got)
}
}
+27 -3
View File
@@ -495,8 +495,16 @@ var Recent = shortcut.Shortcut{
// document list, dropping transport noise (logId) while keeping the pagination // document list, dropping transport noise (logId) while keeping the pagination
// cursor. // cursor.
func recentListProject(data map[string]any) map[string]any { func recentListProject(data map[string]any) map[string]any {
// get_recent_list nests its payload under result.recentItems; earlier this
// read data["recentItems"] at the top level only, so the whole list silently
// projected to empty. Resolve the payload container (top-level or one level
// under result/data) before reading recentItems / pagination fields.
payload := data
if inner, ok := recentListPayload(data); ok {
payload = inner
}
items := []map[string]any{} items := []map[string]any{}
raw, _ := data["recentItems"].([]any) raw, _ := payload["recentItems"].([]any)
for _, it := range raw { for _, it := range raw {
m, ok := it.(map[string]any) m, ok := it.(map[string]any)
if !ok { if !ok {
@@ -512,15 +520,31 @@ func recentListProject(data map[string]any) map[string]any {
}) })
} }
out := map[string]any{"count": len(items), "items": items} out := map[string]any{"count": len(items), "items": items}
if nc, ok := data["nextCursor"]; ok && nc != nil { if nc, ok := payload["nextCursor"]; ok && nc != nil {
out["nextCursor"] = nc out["nextCursor"] = nc
} }
if hm, ok := data["hasMore"]; ok { if hm, ok := payload["hasMore"]; ok {
out["hasMore"] = hm out["hasMore"] = hm
} }
return out return out
} }
// recentListPayload returns the map that actually holds recentItems, tolerating
// a {result|data:{recentItems:[...]}} envelope as well as a bare top-level shape.
func recentListPayload(data map[string]any) (map[string]any, bool) {
if _, ok := data["recentItems"]; ok {
return data, true
}
for _, k := range []string{"result", "data"} {
if inner, ok := data[k].(map[string]any); ok {
if _, ok := inner["recentItems"]; ok {
return inner, true
}
}
}
return nil, false
}
func init() { func init() {
shortcut.Register( shortcut.Register(
List, List,
@@ -0,0 +1,66 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drive
import (
"encoding/json"
"testing"
)
// TestRecentListProjectResultWrapper guards against projection-data-loss:
// get_recent_list nests its payload under result.recentItems; the projection
// must descend into result or +recent silently returns empty despite backend
// records.
func TestRecentListProjectResultWrapper(t *testing.T) {
const raw = `{"result":{"hasMore":true,"nextCursor":"c2","recentItems":[
{"name":"周报","nodeType":"doc","nodeId":"n1","docUrl":"https://x/1"},
{"name":"预算表","nodeType":"sheet","nodeId":"n2","docUrl":"https://x/2"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
out := recentListProject(data)
if got, _ := out["count"].(int); got != 2 {
t.Fatalf("上下层数据不一致: 底层 result.recentItems 有 2 条,投影 count=%v (%v)", out["count"], out)
}
if out["hasMore"] != true || out["nextCursor"] != "c2" {
t.Fatalf("pagination fields lost from result wrapper: %v", out)
}
}
// TestRecentListProjectNoItems covers the no-recentItems branch (payload not
// found under any wrapper), which must yield an empty list, not a panic.
func TestRecentListProjectNoItems(t *testing.T) {
const raw = `{"result":{"totalCount":0},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if out := recentListProject(data); out["count"].(int) != 0 {
t.Fatalf("no recentItems: want count 0, got %v", out["count"])
}
}
// TestRecentListProjectTopLevel covers the already-unwrapped shape.
func TestRecentListProjectTopLevel(t *testing.T) {
const raw = `{"recentItems":[{"name":"周报","nodeId":"n1"}],"hasMore":false}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if out := recentListProject(data); out["count"].(int) != 1 {
t.Fatalf("top-level recentItems: want count 1, got %v", out["count"])
}
}
@@ -0,0 +1,46 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package minutes
import (
"encoding/json"
"testing"
)
// TestCallListProjectItemListShape guards against projection-data-loss end to
// end: list_by_keyword_and_time_range nests the minutes under result.itemList,
// so the resolver must probe "itemList"; and each projected row must carry a
// usable id. The real backend item uses taskUuid, but the API also emits
// minutesId — both must map to the projected taskUuid or callers get rows with
// a title but no id to act on.
func TestCallListProjectItemListShape(t *testing.T) {
const raw = `{"result":{"itemList":[
{"taskUuid":"7632756964333736","title":"周会纪要"},
{"minutesId":"m2","title":"评审纪要"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := callListProject(data)
if len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 result.itemList 有 2 条,投影返回 %d 条", len(got))
}
if got[0]["taskUuid"] != "7632756964333736" {
t.Fatalf("taskUuid-shaped item lost its id: %v", got[0])
}
if got[1]["taskUuid"] != "m2" {
t.Fatalf("minutesId-shaped item did not map to taskUuid: %v", got[1])
}
}
+6 -3
View File
@@ -113,7 +113,7 @@ func callListProject(data map[string]any) []map[string]any {
continue continue
} }
row := map[string]any{} row := map[string]any{}
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "id"); ok { if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "minutesId", "minutes_id", "id"); ok {
row["taskUuid"] = v row["taskUuid"] = v
} }
if v, ok := callListFirst(m, "title", "name"); ok { if v, ok := callListFirst(m, "title", "name"); ok {
@@ -144,7 +144,10 @@ func callListProject(data map[string]any) []map[string]any {
// callListResolveList locates the list payload inside the response, tolerating a // callListResolveList locates the list payload inside the response, tolerating a
// bare top-level array or nesting under result/data/list/items/records containers. // bare top-level array or nesting under result/data/list/items/records containers.
func callListResolveList(data map[string]any) []any { func callListResolveList(data map[string]any) []any {
for _, key := range []string{"result", "data", "list", "items", "records", "dataList"} { // list_by_keyword_and_time_range nests the minutes under result.itemList;
// "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently
// return empty despite the backend returning minutes.
for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} {
v, ok := data[key] v, ok := data[key]
if !ok { if !ok {
continue continue
@@ -154,7 +157,7 @@ func callListResolveList(data map[string]any) []any {
} }
// container may itself wrap the list one level deeper // container may itself wrap the list one level deeper
if inner, ok := v.(map[string]any); ok { if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "records", "dataList", "result", "data"} { for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} {
if arr, ok := inner[ik].([]any); ok { if arr, ok := inner[ik].([]any); ok {
return arr return arr
} }
@@ -0,0 +1,82 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package oa
import (
"encoding/json"
"testing"
)
// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss
// class: list_user_visible_process nests the forms under result.processCodeList.
// The resolver MUST probe that exact key — otherwise the whole list silently
// projects to empty (exit 0, no error envelope) while the backend has data.
func TestListFormsProjectProcessCodeListShape(t *testing.T) {
// Faithful list_user_visible_process shape (as returned by the backend).
const raw = `{"result":{"processCodeList":[
{"processCode":"PROC-1","processName":"请假","dirName":"假勤管理"},
{"processCode":"PROC-2","processName":"补卡申请","dirName":"假勤管理"},
{"processCode":"PROC-3","processName":"加班","dirName":"假勤管理"}
],"totalCount":-1},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
forms := listFormsProject(data)
if len(forms) != 3 {
t.Fatalf("上下层数据不一致: 底层有 3 张表单,投影返回 %d 张 (forms=%v)", len(forms), forms)
}
for _, f := range forms {
if f["processCode"] == nil || f["name"] == nil {
t.Fatalf("projected form missing processCode/name: %v", f)
}
}
}
// TestListFormsProjectBareArrayShape ensures a bare top-level array still works.
func TestListFormsProjectBareArrayShape(t *testing.T) {
const raw = `{"result":[
{"processCode":"PROC-9","processName":"通用审批"}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if forms := listFormsProject(data); len(forms) != 1 {
t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms)
}
}
// TestOAInstanceResolveListValuesShape guards the shared resolver behind
// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval
// instance tools nest the list under result.values; the resolver must probe
// "values" or all four commands silently return empty despite backend records.
func TestOAInstanceResolveListValuesShape(t *testing.T) {
const raw = `{"result":{"hasMore":false,"values":[
{"processInstanceId":"i-1","title":"测试用户的请假"},
{"processInstanceId":"i-2","title":"测试用户的报销"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := oaInstanceResolveList(data); len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 result.values 有 2 条,resolver 返回 %d 条", len(got))
}
// End-to-end through a real projection that uses the shared resolver.
if instances := listSubmittedProject(data); len(instances) != 2 {
t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances)
}
}
+13 -4
View File
@@ -137,7 +137,11 @@ func oaFormResolveList(data map[string]any) []any {
if data == nil { if data == nil {
return []any{} return []any{}
} }
for _, key := range []string{"result", "data", "list", "items", "processList", "forms"} { // list_user_visible_process nests the forms under result.processCodeList;
// probing must include that exact key (both at the top level in case the
// envelope is already unwrapped, and one level deeper under result/data) or
// the whole list silently projects to empty.
for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} {
v, ok := data[key] v, ok := data[key]
if !ok { if !ok {
continue continue
@@ -146,7 +150,7 @@ func oaFormResolveList(data map[string]any) []any {
return arr return arr
} }
if inner, ok := v.(map[string]any); ok { if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "processList", "forms", "result", "data"} { for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} {
if arr, ok := inner[ik].([]any); ok { if arr, ok := inner[ik].([]any); ok {
return arr return arr
} }
@@ -285,7 +289,12 @@ func oaInstanceResolveList(data map[string]any) []any {
if data == nil { if data == nil {
return []any{} return []any{}
} }
for _, key := range []string{"result", "data", "list", "items", "instances", "tasks"} { // The approval instance tools (list_pending_approvals, get_done_tasks,
// get_submitted_instances, get_noticed_instances) all nest the instance list
// under result.values; "values" MUST be in the probe set or every one of
// +list-pending / +list-executed / +list-submitted / +list-cc silently
// projects to empty despite the backend returning records.
for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} {
v, ok := data[key] v, ok := data[key]
if !ok { if !ok {
continue continue
@@ -294,7 +303,7 @@ func oaInstanceResolveList(data map[string]any) []any {
return arr return arr
} }
if inner, ok := v.(map[string]any); ok { if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "instances", "tasks", "result", "data"} { for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} {
if arr, ok := inner[ik].([]any); ok { if arr, ok := inner[ik].([]any); ok {
return arr return arr
} }
+6 -2
View File
@@ -131,7 +131,11 @@ func reportEntryListResolveList(data map[string]any) []any {
if data == nil { if data == nil {
return []any{} return []any{}
} }
for _, key := range []string{"result", "data", "list", "items", "reportList", "records"} { // get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); "report_list" MUST be probed — the
// camelCase "reportList" alone leaves +inbox-list / +outbox-list silently
// empty despite the backend returning reports.
for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} {
v, ok := data[key] v, ok := data[key]
if !ok { if !ok {
continue continue
@@ -140,7 +144,7 @@ func reportEntryListResolveList(data map[string]any) []any {
return arr return arr
} }
if inner, ok := v.(map[string]any); ok { if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "reportList", "records", "result", "data"} { for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} {
if arr, ok := inner[ik].([]any); ok { if arr, ok := inner[ik].([]any); ok {
return arr return arr
} }
@@ -0,0 +1,38 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package report
import (
"encoding/json"
"testing"
)
// TestReportEntryListResolveListSnakeShape guards against projection-data-loss:
// get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); the resolver must probe "report_list" or
// +inbox-list / +outbox-list silently return empty despite the backend having
// reports.
func TestReportEntryListResolveListSnakeShape(t *testing.T) {
const raw = `{"result":{"report_list":[
{"reportId":"r1","templateName":"日报"},
{"reportId":"r2","templateName":"周报"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := reportEntryListResolveList(data); len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 result.report_list 有 2 条,resolver 返回 %d 条", len(got))
}
}
+6 -7
View File
@@ -55,12 +55,12 @@ var CreatedTodos = shortcut.Shortcut{
`dws todo +created-todos`, `dws todo +created-todos`,
}, },
Execute: func(rt *shortcut.RuntimeContext) error { Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my todos as creator. pageNum/pageSize are strings and // Step 1 — list my todos as creator via the shared pager. This keeps
// roleTypes=["creator"] mirrors helpers.buildListTodoTaskArgs, where // pageSize at todoPageSize(=20): the backend silently returns an empty
// "creator" is an accepted role-type enum value. // page for pageSize>20, so the previous hard-coded pageSize=50 made this
data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", map[string]any{ // command always return empty even when the creator had todos. The pager
"pageNum": "1", // also merges every page instead of only the first.
"pageSize": "50", cards, err := shortcutListAllTodoCards(rt, map[string]any{
"roleTypes": []string{"creator"}, "roleTypes": []string{"creator"},
}) })
if err != nil { if err != nil {
@@ -69,7 +69,6 @@ var CreatedTodos = shortcut.Shortcut{
// Step 2 — project each card to {subject, taskId, dueTime}, parsing // Step 2 — project each card to {subject, taskId, dueTime}, parsing
// fields defensively (helpers reused from todo_done.go / overdue.go). // fields defensively (helpers reused from todo_done.go / overdue.go).
cards := shortcutTodoCards(data)
created := make([]map[string]any, 0, len(cards)) created := make([]map[string]any, 0, len(cards))
for _, m := range cards { for _, m := range cards {
subject, _ := m["subject"].(string) subject, _ := m["subject"].(string)
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
var errStubTool = errors.New("stub tool error")
// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with
// an email field and bare string arrays, each at the top level or one level
// under a result/data wrapper. The nested bare-string case regressed silently
// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly
// demand an explicit --email.
func TestSearchMailPickMailbox(t *testing.T) {
cases := []struct {
name, raw, want string
}{
{"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"},
{"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"},
{"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"},
{"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"},
{"empty", `{"result":{"emailAccounts":[]}}`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var data map[string]any
if err := json.Unmarshal([]byte(tc.raw), &data); err != nil {
t.Fatal(err)
}
if got := searchMailPickMailbox(data); got != tc.want {
t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
// TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces
// container is probed — otherwise +resolve-space silently finds no candidates.
func TestResolveSpaceItemsWikiSpaces(t *testing.T) {
const raw = `{"result":{"wikiSpaces":[
{"spaceId":"s1","name":"研发知识库"},
{"spaceId":"s2","name":"产品知识库"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatal(err)
}
if got := resolveSpaceItems(data); len(got) != 2 {
t.Fatalf("上下层数据不一致: 底层 result.wikiSpaces 有 2 条,resolver 返回 %d 条", len(got))
}
}
// --- fake MCP caller for end-to-end shortcut projection tests ---
type stubMailboxCaller struct {
byTool map[string]string
errTool string // tool name that should return a transport error
}
func (f *stubMailboxCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
if tool == f.errTool {
return nil, errStubTool
}
text := f.byTool[tool]
if text == "" {
text = `{"result":{}}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (f *stubMailboxCaller) Format() string { return "json" }
func (f *stubMailboxCaller) DryRun() bool { return false }
func (f *stubMailboxCaller) Fields() string { return "" }
func (f *stubMailboxCaller) JQ() string { return "" }
func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
t.Helper()
helpers.InitDeps(fake)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(io.Discard)
root.SetArgs(argv)
if err := root.Execute(); err != nil {
t.Fatalf("execute %v: %v", argv, err)
}
return buf.String()
}
func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error {
t.Helper()
helpers.InitDeps(fake)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(argv)
return root.Execute()
}
// TestSmartSearchMailNoMailbox covers the empty-mailbox path: when
// list_user_mailboxes yields no address, +search-mail must surface a clear
// "provide --email" validation error rather than silently proceeding.
func TestSmartSearchMailNoMailbox(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
"list_user_mailboxes": `{"result":{"emailAccounts":[]}}`,
}}
err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json")
if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") {
t.Fatalf("want no-mailbox validation error, got %v", err)
}
}
// TestSmartSearchMailBackendError covers the transport-error branch of the
// mailbox resolver.
func TestSmartSearchMailBackendError(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "list_user_mailboxes"}
if err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json"); err == nil {
t.Fatal("want backend error propagated, got nil")
}
}
// TestCreatedTodosBackendError covers the error branch of +created-todos when the
// paged todo fetch fails.
func TestCreatedTodosBackendError(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "get_user_todos_in_current_org"}
if err := runShortcutErr(t, fake, "todo", "+created-todos", "--format", "json"); err == nil {
t.Fatal("want backend error propagated, got nil")
}
}
// TestCreatedTodosProjectsCards runs +created-todos end to end through a fake MCP
// caller. get_user_todos_in_current_org nests cards under result.todoCards; the
// shared pager (pageSize=20, not the old 50 that silently returned empty) must
// surface them into the projected "created" list.
func TestCreatedTodosProjectsCards(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
"get_user_todos_in_current_org": `{"result":{"todoCards":[
{"subject":"待办A","taskId":"111","dueTime":1784686500000},
{"subject":"待办B","taskId":"222"}
]}}`,
}}
out := runShortcut(t, fake, "todo", "+created-todos", "--format", "json")
var d map[string]any
if err := json.Unmarshal([]byte(out), &d); err != nil {
t.Fatalf("output not json: %q", out)
}
created, _ := d["created"].([]any)
if len(created) != 2 {
t.Fatalf("上下层数据不一致: 底层 2 条待办,投影 created=%d (%s)", len(created), out)
}
if !strings.Contains(out, "111") || !strings.Contains(out, "222") {
t.Fatalf("projected rows missing taskId: %s", out)
}
}
// TestSmartSearchMailAutoResolvesMailbox runs +search-mail without --email so the
// shortcut must auto-resolve the mailbox from list_user_mailboxes
// (result.emailAccounts) before searching. Before the fix this returned
// "未找到可用邮箱" because emailAccounts was not probed.
func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
"list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`,
"search_emails": `{"result":{"messages":[{"subject":"周报","from":"a@x.com"}]}}`,
}}
out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:周报", "--format", "json")
if strings.Contains(out, "未找到可用邮箱") || out == "" {
t.Fatalf("mailbox auto-resolve failed: %s", out)
}
if !strings.Contains(out, "周报") {
t.Fatalf("search result not projected: %s", out)
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any {
if data == nil { if data == nil {
return nil return nil
} }
for _, key := range []string{"result", "data", "list", "items", "spaces", "records"} { for _, key := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "records"} {
raw, ok := data[key] raw, ok := data[key]
if !ok { if !ok {
continue continue
+36 -10
View File
@@ -112,23 +112,49 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
boxes := searchMailUnwrapList(data, "mailboxes", "list", "items", "data", "result", "records") if addr := searchMailPickMailbox(data); addr != "" {
for _, m := range boxes { return addr, nil
}
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
}
// searchMailPickMailbox resolves the first usable mailbox address from a
// list_user_mailboxes response. It tolerates every observed shape: objects with
// an email field (result.emailAccounts[].email) and bare string arrays
// (result.emailAccounts[]), each at the top level or one level under a
// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string
// case is scanned separately — otherwise all three smart mail shortcuts wrongly
// report that no mailbox exists.
func searchMailPickMailbox(data map[string]any) string {
containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"}
for _, m := range searchMailUnwrapList(data, containers...) {
if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" { if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" {
return addr, nil return addr
} }
} }
// Some gateways return the addresses as a bare string list. scanStrings := func(m map[string]any) string {
for _, key := range []string{"mailboxes", "list", "items", "data", "result"} { for _, key := range containers {
if arr, ok := data[key].([]any); ok { if arr, ok := m[key].([]any); ok {
for _, it := range arr { for _, it := range arr {
if s, ok := it.(string); ok && s != "" { if s, ok := it.(string); ok && s != "" {
return s, nil return s
}
} }
} }
} }
return ""
} }
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") if s := scanStrings(data); s != "" {
return s
}
for _, wrap := range []string{"result", "data"} {
if inner, ok := data[wrap].(map[string]any); ok {
if s := scanStrings(inner); s != "" {
return s
}
}
}
return ""
} }
// searchMailMessages extracts the message list from a search_emails response. // searchMailMessages extracts the message list from a search_emails response.
@@ -0,0 +1,49 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package wiki
import (
"encoding/json"
"testing"
)
// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss:
// list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces;
// the resolver must probe "wikiSpaces" or +space-list / +space-search silently
// return empty despite the backend returning spaces.
func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
const raw = `{"result":{"hasMore":false,"wikiSpaces":[
{"workspaceId":"w1","name":"研发知识库"},
{"workspaceId":"w2","name":"产品知识库"}
]},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if spaces := spaceListProject(data); len(spaces) != 2 {
t.Fatalf("上下层数据不一致: 底层 result.wikiSpaces 有 2 条,投影返回 %d 条 (%v)", len(spaces), spaces)
}
}
// TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape.
func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) {
const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"研发知识库"}]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if spaces := spaceListProject(data); len(spaces) != 1 {
t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces)
}
}
+5 -2
View File
@@ -94,12 +94,15 @@ func spaceListProject(data map[string]any) []map[string]any {
// wikiSpaceRawList locates the space array across candidate container keys, // wikiSpaceRawList locates the space array across candidate container keys,
// tolerating a nested {result|data:{list|items|spaces}} wrapper. // tolerating a nested {result|data:{list|items|spaces}} wrapper.
func wikiSpaceRawList(data map[string]any) []any { func wikiSpaceRawList(data map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "spaces", "workspaces"} { // list_wikiSpaces / search_wikiSpaces nest the space list under
// result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces"
// MUST be probed or +space-list / +space-search silently return empty.
for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} {
if arr, ok := data[k].([]any); ok { if arr, ok := data[k].([]any); ok {
return arr return arr
} }
if inner, ok := data[k].(map[string]any); ok { if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "spaces", "workspaces", "result", "data"} { for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} {
if arr, ok := inner[ik].([]any); ok { if arr, ok := inner[ik].([]any); ok {
return arr return arr
} }
+35 -4
View File
@@ -6,16 +6,25 @@ from __future__ import annotations
import argparse import argparse
import datetime as dt import datetime as dt
import json import json
import os
import shlex import shlex
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
from shortcut_real_result import classify_failure, summarize_failure_categories, classify_real_status, summarize_results from shortcut_real_result import (
classify_failure,
summarize_failure_categories,
classify_real_status,
summarize_results,
projection_audit,
)
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
OUT_PATH = ROOT / "docs" / "shortcut-real-write-results.json" OUT_PATH = Path(
os.environ.get("SHORTCUT_REAL_RESULTS_PATH", ROOT / "docs" / "shortcut-real-write-results.json")
)
def load_report() -> dict: def load_report() -> dict:
@@ -39,6 +48,12 @@ def main() -> int:
p.add_argument("--risk", default="write") p.add_argument("--risk", default="write")
p.add_argument("--method", default="real-backend-manual-write; no --mock; no --dry-run") p.add_argument("--method", default="real-backend-manual-write; no --mock; no --dry-run")
p.add_argument("--timeout", type=int, default=35) p.add_argument("--timeout", type=int, default=35)
p.add_argument(
"--backend-raw",
help="Path to a JSON file holding the lower-layer MCP tool response for "
"this shortcut (captured separately). Used only in memory to compare the "
"upper projection against the lower layer; never persisted.",
)
p.add_argument("argv", nargs=argparse.REMAINDER) p.add_argument("argv", nargs=argparse.REMAINDER)
ns = p.parse_args() ns = p.parse_args()
argv = ns.argv argv = ns.argv
@@ -47,6 +62,12 @@ def main() -> int:
if not argv: if not argv:
raise SystemExit("missing command argv") raise SystemExit("missing command argv")
# Lower layer stays in memory only — we persist derived counts, never the raw
# PII-bearing backend payload.
backend_raw = None
if ns.backend_raw:
backend_raw = json.loads(Path(ns.backend_raw).read_text(encoding="utf-8"))
started = time.time() started = time.time()
status = "real-error" status = "real-error"
stdout = "" stdout = ""
@@ -57,7 +78,7 @@ def main() -> int:
stdout = proc.stdout.strip() stdout = proc.stdout.strip()
stderr = proc.stderr.strip() stderr = proc.stderr.strip()
exit_code = proc.returncode exit_code = proc.returncode
status = classify_real_status(exit_code, stdout) status = classify_real_status(exit_code, stdout, backend_raw=backend_raw)
except subprocess.TimeoutExpired as e: except subprocess.TimeoutExpired as e:
status = "timeout" status = "timeout"
if isinstance(e.stdout, str): if isinstance(e.stdout, str):
@@ -83,7 +104,17 @@ def main() -> int:
"duration_ms": duration_ms, "duration_ms": duration_ms,
"recorded_at": dt.datetime.now().isoformat(), "recorded_at": dt.datetime.now().isoformat(),
} }
category, fixability, note = classify_failure(result) # Compare upper projection vs lower layer in memory; persist only the derived
# verdict (kind + counts), never the raw backend payload.
if backend_raw is not None:
audit = projection_audit({**result, "backend": backend_raw})
if audit is not None:
result["projection_audit"] = audit
# classify_failure inspects the lower layer to flag projection-data-loss, so
# hand it a copy carrying the in-memory backend; the persisted `result` keeps
# only the verdict.
category, fixability, note = classify_failure({**result, "backend": backend_raw})
if category != "passed": if category != "passed":
result["failure_category"] = category result["failure_category"] = category
result["fixability"] = fixability result["fixability"] = fixability
+156 -1
View File
@@ -80,13 +80,120 @@ def payload_indicates_error(stdout: str) -> bool:
return False return False
def classify_real_status(exit_code: int | None, stdout: str, current_status: str | None = None) -> str: # ---------------------------------------------------------------------------
# 上层 / 下层数据一致性(投影保真)
#
# 警示案例:contact +list-roles、oa +list-forms 在 exit 0、无 error 信封的情况下
# 依然静默返空——底层 MCP 明明有 57 个角色 / 93 张表单,投影层因为容器 key 对不上
# (result[].labels[] 未下钻 / 缺 processCodeList)把数据全吃掉了。只看上层(便捷层
# 投影输出)永远发现不了这类问题;**shortcut 务必对比上层投影与下层原始后端数据**。
#
# 判定规则因此新增一条铁律:一个只读/列表类 shortcut,若上层投影为空,必须能拿下层
# 原始响应来佐证「本就没有数据」。拿不到下层、或下层明明有数据,都不能算 real-ok。
# ---------------------------------------------------------------------------
# 便捷层投影输出里承载业务列表的常见容器字段名。
_PROJECTION_LIST_KEYS = (
"roles", "forms", "users", "user", "items", "list", "records", "files",
"docs", "nodes", "messages", "conversations", "groups", "events",
"attendees", "rooms", "threads", "folders", "tags", "templates", "contacts",
"spaces", "tasks", "todos", "created", "members", "depts", "departments",
"sheets", "bases", "tables", "views", "apps", "minutes", "reports",
"comments", "attachments", "instances", "robots", "bots", "results",
"workflows", "permissions", "versions", "calendars", "cards",
)
def count_projection_items(stdout: str) -> int | None:
"""从便捷层投影输出里数出条目数(上层)。
返回 None 表示这段输出不像一个「列表型」投影(例如详情命令、纯文本、写操作
回执),此时不参与空投影判定。识别到列表型投影时返回其条目数(可能为 0)。
"""
best: int | None = None
for value in _json_values(stdout):
if isinstance(value, list):
best = max(best or 0, len(value))
continue
if not isinstance(value, dict):
continue
count_field = value.get("count")
if isinstance(count_field, bool):
count_field = None
if isinstance(count_field, int):
best = max(best or 0, count_field)
for key in _PROJECTION_LIST_KEYS:
member = value.get(key)
if isinstance(member, list):
best = max(best or 0, len(member))
return best
def backend_record_count(raw: Any, depth: int = 0) -> int:
"""递归估算下层原始后端响应里的业务条目数(下层真值)。
取任意深度下「最大的一个对象数组」的长度作为「后端是否有数据」的代理指标——
足以判定投影是否把非空的底层数据吃成了空。
"""
if depth > 6 or raw is None:
return 0
best = 0
if isinstance(raw, list):
obj_items = sum(1 for item in raw if isinstance(item, dict))
best = max(best, obj_items)
for item in raw:
best = max(best, backend_record_count(item, depth + 1))
elif isinstance(raw, dict):
for value in raw.values():
best = max(best, backend_record_count(value, depth + 1))
return best
def _backend_payload(result: dict[str, Any]) -> Any:
"""取出随 result 一起记录的下层原始后端响应(若采集了的话)。
约定字段:backend_raw / raw_backend / backend / lower_layer / backend_stdout。
backend_stdout 是字符串时按 JSON 解析。"""
for key in ("backend_raw", "raw_backend", "backend", "lower_layer"):
if key in result and result[key] not in (None, "", {}, []):
return result[key]
text = result.get("backend_stdout")
if isinstance(text, str) and text.strip():
values = _json_values(text)
if values:
return values[0] if len(values) == 1 else values
return None
def compare_layers(shortcut_stdout: str, backend_raw: Any) -> tuple[bool, int | None, int]:
"""对比上层投影与下层原始后端数据。
返回 (是否投影吃了数据, 上层条目数, 下层条目数)。当上层是列表型投影且为空、
而下层明显有对象数组时,判定为投影数据丢失(True)。"""
upper = count_projection_items(shortcut_stdout)
lower = backend_record_count(backend_raw)
lost = upper == 0 and lower > 0
return lost, upper, lower
def classify_real_status(
exit_code: int | None,
stdout: str,
current_status: str | None = None,
*,
backend_raw: Any = None,
) -> str:
if current_status in {"timeout", "held"}: if current_status in {"timeout", "held"}:
return current_status return current_status
if exit_code != 0: if exit_code != 0:
return "real-error" return "real-error"
if payload_indicates_error(stdout): if payload_indicates_error(stdout):
return "real-error" return "real-error"
# 上下层比对:下层有数据但上层投影为空 = 投影吃数据,绝不能算通过。
if backend_raw is not None:
lost, _upper, _lower = compare_layers(stdout, backend_raw)
if lost:
return "real-error"
return "real-ok" return "real-ok"
@@ -97,6 +204,22 @@ def _haystack(result: dict[str, Any]) -> str:
def classify_failure(result: dict[str, Any]) -> tuple[str, str, str]: def classify_failure(result: dict[str, Any]) -> tuple[str, str, str]:
"""Return (category, fixability, note) for a real-test result.""" """Return (category, fixability, note) for a real-test result."""
status = result.get("status") status = result.get("status")
# 上下层比对优先:即便进程 exit 0、无 error 信封、status 被记成 real-ok,只要
# 采集到的下层原始后端有数据而上层投影为空,就是投影把数据吃了——判定为
# projection-data-loss,属于 shortcut 自身可修的 bug(改投影层的容器 key/下钻)。
backend_raw = _backend_payload(result)
if backend_raw is not None and result.get("exit_code") in (0, None):
lost, upper, lower = compare_layers(result.get("stdout") or "", backend_raw)
if lost:
return (
"projection-data-loss",
"cli-projection-fix-needed",
f"上层便捷层投影为空({upper} 条),但下层原始后端有数据(约 {lower} 条);"
"投影层的容器 key/嵌套下钻与真实响应结构不匹配,把非空数据吃成了空。"
"需修投影层解析,并补「喂真实响应结构、断言非空」的单测。",
)
if status == "real-ok": if status == "real-ok":
return ("passed", "fixed", "真实后端执行成功。") return ("passed", "fixed", "真实后端执行成功。")
if status == "timeout": if status == "timeout":
@@ -263,6 +386,38 @@ def summarize_failure_categories(results: list[dict[str, Any]]) -> dict[str, int
return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0]))) return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0])))
def projection_audit(result: dict[str, Any]) -> dict[str, Any] | None:
"""投影保真审计:对只读/列表类 shortcut 强制「上下层比对」这条铁律。
返回 None 表示无需关注;否则返回一个 warning dict 供报告展示:
- projection-data-loss:下层有数据、上层空 —— 确定的投影吃数据 bug;
- empty-projection-unverified:上层投影为空但缺下层采集 —— 无法证明「本就为空」,
必须补采下层原始响应(原始 MCP 响应 / 对应 leaf 命令)再比对,禁止直接判 real-ok。
"""
stdout = result.get("stdout") or ""
upper = count_projection_items(stdout)
if upper is None or upper > 0:
return None # 非列表型投影,或上层本就有数据 —— 最常见的正常情形。
backend_raw = _backend_payload(result)
if backend_raw is None:
return {
"kind": "empty-projection-unverified",
"upper_count": upper,
"backend_count": None,
"note": "便捷层投影为空,但未采集下层原始后端响应,无法证明底层确实无数据;"
"按铁律必须补采下层后再比对,不能直接判 real-ok。",
}
lost, _upper, lower = compare_layers(stdout, backend_raw)
if lost:
return {
"kind": "projection-data-loss",
"upper_count": upper,
"backend_count": lower,
"note": "下层原始后端有数据但上层投影为空:投影层解析与真实响应结构不匹配。",
}
return None
_OSS_URL_RE = re.compile(r"https?://[^\s\"'<>]*oss[^\s\"'<>]*", re.IGNORECASE) _OSS_URL_RE = re.compile(r"https?://[^\s\"'<>]*oss[^\s\"'<>]*", re.IGNORECASE)
_ALIBABA_ACCESS_KEY_ID_RE = re.compile(r"\bLTAI[A-Za-z0-9]{12,}\b") _ALIBABA_ACCESS_KEY_ID_RE = re.compile(r"\bLTAI[A-Za-z0-9]{12,}\b")
_OSS_QUERY_KEY_RE = re.compile( _OSS_QUERY_KEY_RE = re.compile(
+204
View File
@@ -0,0 +1,204 @@
#!/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()
@@ -0,0 +1,40 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package scripts_test
import (
"os/exec"
"path/filepath"
"testing"
)
// TestShortcutRealResultSelfTest wires scripts/shortcut_real_result_test.py into
// `go test ./...` so the upper/lower projection-audit rules (and the end-to-end
// record_real_shortcut_run.py integration) are enforced in CI, not manual-only.
func TestShortcutRealResultSelfTest(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("repo root: %v", err)
}
py, err := exec.LookPath("python3")
if err != nil {
t.Skipf("python3 not available: %v", err)
}
cmd := exec.Command(py, filepath.Join("scripts", "shortcut_real_result_test.py"))
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("shortcut_real_result_test.py failed: %v\n%s", err, out)
}
}