Compare commits

...

19 Commits

Author SHA1 Message Date
zhangheng.023
d48f84c096 opt(round-003): 复测 效果+0.200 token+17 耗时-1612ms → 采纳 2026-06-23 20:09:03 +08:00
zhangheng.023
cbd6e56ac0 opt(round-003): references/lark-im-chat-create.md — dedup Commands/Scenarios overlap + compress --help-mirroring Common Errors into pointers, keep 232043 two-step flow & all guardrails 2026-06-23 19:51:49 +08:00
zhangheng.023
572eb8da41 opt(round-002): 复测 效果+0.400 token-2464 耗时+771ms → 采纳 2026-06-23 19:37:11 +08:00
zhangheng.023
82a099feaf opt(round-002): lark-im-messages-send.md — consolidate 4x repeated content-flag rule, compress media enumeration & --help-mirror sections 2026-06-23 19:17:24 +08:00
zhangheng.023
51f2a70e6d opt(round-001): 复测 效果+0.156 token+7779 耗时+17836ms → 采纳 2026-06-23 18:51:18 +08:00
zhangheng.023
237a77feb3 opt(round-001): SKILL.md — fold USAGE method-index + scope table into a schema pointer (-40% resident tokens) 2026-06-23 18:27:25 +08:00
zhangheng.023
040ef17eae opt(baseline): 题库 + Φ0 基线产物(优化起点) 2026-06-23 18:12:57 +08:00
liangshuo-1
736b131cdf fix(meta): backfill enum value descriptions from options (#1541) 2026-06-23 16:14:42 +08:00
arnold9672
5efaf65aec feat: surface search API notices (#1413)
* feat: surface search API notices

sa: safe
doc: none
cfg: none
test: unit test

* fix: surface search notices in default output

* docs: add search notice doc comments

* docs: expand search notice doc comments
2026-06-23 14:27:04 +08:00
linchao5102
0991da7446 fix: add missing CLI headers for git credential helper (#1539) 2026-06-23 14:25:26 +08:00
zgz2048
80bea45c6a feat: support base record comments (#1043)
* feat: support base record comments

* fix: tighten base comment validation

* fix: validate wiki base comment flags
2026-06-23 11:20:07 +08:00
bubbmon233
c775cb4360 docs(mail): trim lark-mail skill context (#1527) 2026-06-22 21:32:31 +08:00
jiangguozhou
824aa9edf8 docs: add lark-drive permission governance workflow (#1292)
Change-Id: Ib62bd439669fec3e9d5589d1fbe266d3aef964a8
2026-06-22 14:20:02 +08:00
zhanghuanxu
9d4ae94394 feat(slides):slide screenshot 2026-06-22 13:20:39 +08:00
liangshuo-1
bba13cfe0f chore: release v1.0.56 (#1518) 2026-06-18 18:53:21 +08:00
liujiashu-shiro
815cdb8f1c feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
2026-06-18 17:53:22 +08:00
liangshuo-1
4f3ae0c71a fix: pin fetch_meta.py output to utf-8 encoding (#1516) 2026-06-18 17:18:45 +08:00
91-enjoy
96d70143c5 feat: support message recieve event card format (#1480)
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.

The output format is <card title="..." subtitle="...">...</card>, with each card element type
serialised to a readable representation (markdown body, button links, table rows, chart summaries,
etc.).
2026-06-18 17:18:01 +08:00
syh-cpdsss
83db15907f Improve OKR shortcuts (#1487)
* feat(okr): add +batch-create, +reorder, +weight shortcuts

Add three new OKR shortcuts for managing objectives and key results:

- +batch-create: Bulk create objectives with key results, with automatic
  rollback on failure
- +reorder: Adjust position of objectives or key results within a cycle/objective
- +weight: Adjust weights of objectives or key results with automatic
  normalization using fixed-point arithmetic to avoid float precision issues

Key implementation details:
- API paths use underscore separators (/objectives_position, /objectives_weight)
- Weight normalization uses json.Number for precise JSON serialization
- Items are sorted by position before API calls to match backend requirements
- Full unit test coverage and dry-run/live E2E tests
- Skill documentation with usage examples and parameter descriptions

Change-Id: I92b658e0cc42ffa8cbdaec2ec628a079bcfc38f5

* fix: skill simplify & minor fix

Change-Id: I3f27a01cdae2122f26e48ee2acb7f334f2bab7d2

* fix: CR issue

Change-Id: Id9fab84e06f0d67e9f79c1fb9946b6b633200592

* fix: CR issue 2

Change-Id: I6a5e57dd4b10dc79f8681ec614354fbba82abc04

* fix: error handle of +weight shortcut

Change-Id: I6e2a39269e62e3b504e681110843b2ccc315a527
2026-06-18 16:25:23 +08:00
144 changed files with 21367 additions and 940 deletions

View File

@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file.
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
@@ -1189,6 +1212,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

5
harness-opt/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# harness-opt 只入库轻量决策记录;重的原始评测 run 不进版本库(dashboard 仍读磁盘)。
baseline/runs/
**/child-runs/
verify_results/sealed-runs/
verify_results/*-runs/

View File

@@ -0,0 +1,5 @@
{
"1": 30086,
"2": 34616,
"3": 31289
}

View File

@@ -0,0 +1,50 @@
{
"k": 5,
"metrics": {
"success_rate": {
"mean": 0.4666666666666666,
"std": 0.1632993161855452,
"k": 5,
"band": [
0.14006803429557624,
0.793265299037757
]
},
"mean_score": {
"mean": 0.5111111111111111,
"std": 0.1507184440694504,
"k": 5,
"band": [
0.20967422297221028,
0.8125479992500119
]
},
"mean_context_window": {
"mean": 31997.0,
"std": 7166.8411203573105,
"k": 5,
"band": [
17663.31775928538,
46330.682240714625
]
},
"mean_duration_ms": {
"mean": 50188.86666666667,
"std": 7746.3168641619595,
"k": 5,
"band": [
34696.23293834275,
65681.50039499058
]
},
"mean_token": {
"mean": 263981.06666666665,
"std": 27890.193480385413,
"k": 5,
"band": [
208200.67970589583,
319761.45362743747
]
}
}
}

View File

@@ -0,0 +1,33 @@
{
"k": 5,
"n_cases": 3,
"effect": {
"mean": 0.5111111111111111,
"sigma": 0.1507184440694504
},
"token": {
"mean": 31997.0,
"sigma": 7166.8411203573105
},
"duration": {
"mean": 50188.86666666667,
"sigma": 7746.3168641619595
},
"phi0_per_case": {
"1": {
"effect": 0.6,
"token": 30086,
"duration": 51004
},
"2": {
"effect": 0.4,
"token": 34616,
"duration": 52787
},
"3": {
"effect": 0.5333,
"token": 31289,
"duration": 46776
}
}
}

869
harness-opt/coverage.json Normal file
View File

@@ -0,0 +1,869 @@
{
"summary": {
"total_cases": 3,
"files": 25,
"expected_declared": 0,
"blind_spots": 22,
"overfit_high": 5,
"suggest_add_cases": [
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-search.md"
],
"suggest_fix_routing": []
},
"files": [
{
"path": "skills/lark-im/references/lark-im-chat-identity.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 5,
"R1": 0,
"R2": 0,
"R3": 50
},
"total_lines": 55,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-search.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 6,
"R1": 85,
"R2": 112,
"R3": 31
},
"total_lines": 234,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-cancel.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 6,
"R1": 25,
"R2": 21,
"R3": 15
},
"total_lines": 67,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-create.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 7,
"R1": 25,
"R2": 20,
"R3": 15
},
"total_lines": 67,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-message-enrichment.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 1,
"R1": 0,
"R2": 43,
"R3": 10
},
"total_lines": 54,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-messages-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 90,
"R2": 40,
"R3": 22
},
"total_lines": 157,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-reply.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 139,
"R2": 109,
"R3": 14
},
"total_lines": 263,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-groups.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 50,
"R1": 368,
"R2": 22,
"R3": 12
},
"total_lines": 452,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-search.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 102,
"R2": 24,
"R3": 11
},
"total_lines": 142,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-update.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 67,
"R2": 2,
"R3": 10
},
"total_lines": 84,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-resources-download.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 55,
"R2": 24,
"R3": 10
},
"total_lines": 94,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-threads-messages-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 72,
"R2": 28,
"R3": 9
},
"total_lines": 115,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 103,
"R2": 56,
"R3": 6
},
"total_lines": 166,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 80,
"R2": 9,
"R3": 6
},
"total_lines": 100,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-reactions.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 73,
"R1": 206,
"R2": 18,
"R3": 2
},
"total_lines": 299,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-list-item.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 7,
"R1": 44,
"R2": 17,
"R3": 0
},
"total_lines": 68,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 44,
"R2": 15,
"R3": 0
},
"total_lines": 65,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-query-item.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 21,
"R2": 17,
"R3": 0
},
"total_lines": 44,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-create.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 7,
"R1": 70,
"R2": 20,
"R3": 0
},
"total_lines": 97,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 73,
"R2": 24,
"R3": 0
},
"total_lines": 103,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 10,
"R1": 24,
"R2": 14,
"R3": 0
},
"total_lines": 48,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/SKILL.md",
"is_domain_skill": true,
"actual": {
"count": 3,
"pct": 1.0,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 3,
"pct": 1.0,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 3,
"density_pct": 1.0,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 122,
"R1": 0,
"R2": 68,
"R3": 41
},
"total_lines": 231,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-create.md",
"is_domain_skill": false,
"actual": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 2,
"density_pct": 0.667,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 116,
"R2": 12,
"R3": 29
},
"total_lines": 162,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-send.md",
"is_domain_skill": false,
"actual": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 2,
"density_pct": 0.667,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 140,
"R2": 109,
"R3": 14
},
"total_lines": 264,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-mget.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "低",
"risk_lines": {
"R0": 5,
"R1": 84,
"R2": 10,
"R3": 0
},
"total_lines": 99,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
}
]
}

View File

@@ -0,0 +1,48 @@
{
"slug": "im-token",
"modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"modules_spec": [
"skills/lark-im/**/*.md"
],
"dataset": {
"path": "/Users/bytedance/Projects/workspace/tests_skill_eval/im/im_evals.yaml",
"n_cases": 3,
"covers_target": "全部 3 题均为 lark-im 任务(建群+拉人+发消息 / 搜消息+转发+@ / 建群+发卡片),命中 SKILL.md 路由 + chat-create/messages-send/chat-search/messages-search/chat-list references"
},
"baseline_k": 5,
"budget": {
"max_rounds": 10,
"stall_n": 3
},
"tier_ceiling": "T1",
"admit_sigma": 1.0,
"admit_sigma_duration": 1.0,
"admit_sigma_effect": 1.0,
"admit_sigma_target_boost": 0.0
}

View File

@@ -0,0 +1,60 @@
{
"task_id": "OPT-IM-1",
"title": "优化 lark-im省 token 保成功率)",
"branch": "feat/opt-im-token",
"current_phase": "round",
"phase_status": "in_progress",
"started_at": "2026-06-23T17:52:10",
"updated_at": "2026-06-23T19:38:08",
"blockers": null,
"transcript_path": "/Users/bytedance/.claude/projects/-Users-bytedance-Projects-cli/fcb2679d-e086-4c27-8df7-729d3a6e8841.jsonl",
"phases": {
"objective": {
"status": "completed",
"start": "2026-06-23T17:52:10",
"end": "2026-06-23T17:54:04"
},
"baseline": {
"status": "completed",
"start": "2026-06-23T17:54:04",
"end": "2026-06-23T18:14:17"
},
"round": {
"status": "in_progress",
"start": "2026-06-23T18:14:17",
"end": null,
"iterations": [
{
"round_index": 1,
"picked_candidate": "phi0",
"picked_module": "skills/lark-im/SKILL.md",
"tier": "T1",
"verdict": "admit",
"reason": "engine admit=score_gain(eff 0.511→0.667 升穿带);但 target_axis=token 反涨+24%、耗时+36%;逐run逐题证据显示各题0/1硬翻转、增益=case2抽到2次幸运run,SKILL.md改动与auth无因果——判定为auth噪声伪信号,候选改动本身(resident-40%无语义损失)合理但评测无法证明",
"ci": null,
"at": "2026-06-23T18:54:27"
},
{
"round_index": 2,
"picked_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"picked_module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"verdict": "admit",
"reason": "engine admit=score_gain(case080 单题 0.6→1.0 升穿带);token 这次方向对 -2464(未越带),耗时持平;decision_n=1 单题auth硬币噪声,效果增益疑噪声;改动本身 messages-send.md -53.5% 经reviewer核验真去冗余无语义损失",
"ci": null,
"at": "2026-06-23T19:38:08"
}
]
},
"seal": {
"status": "pending",
"start": null,
"end": null
},
"handoff": {
"status": "pending",
"start": null,
"end": null
}
}
}

13
harness-opt/opt-state.md Normal file
View File

@@ -0,0 +1,13 @@
# Opt State: OPT-IM-1 优化 lark-im省 token 保成功率)
## Phase 记录
### ✅ Phase 1: Objective
进入 baseline以现网 lark-im 文档为 Φ0K=5 重复评测立噪声地板
做了什么:确认7项objective(省token保成功率/T1/全lark-im范围/K5/10轮stall3/σ1.0)并写objective.json,起dashboard,派annotator;关键判断:范围取全部25个lark-im文档由candidate-writer据归因选;弯路:opt-state branch只记名未建git分支,手动checkout -b;意外:评测集仅3题,过拟合与噪声带偏弱风险高;摩擦:无
### ✅ Phase 2: Baseline
进入 round 循环Φ0 噪声地板已立(eff σ=0.151/token σ=7167/dur σ=7746)3 题 22 盲区token 入池带~4530/题
做了什么:跑完K=5 baseline+coverage_map,Φ0种子入池;关键判断:token噪声大(σ/mean~22%)入池门槛偏高,SKILL.md常驻是reach全集的最高杠杆;弯路:无;意外:22/25文件是盲区,reach会天然把候选限制到SKILL.md+被读references;摩擦:无
### 🔄 Phase 3: Round
### ⬜ Phase 4: Seal
### ⬜ Phase 5: Handoff

View File

@@ -0,0 +1,12 @@
{
"id": "53194d7a111df326cc078b633f43587225bd0132",
"worktree": "/Users/bytedance/Projects/cli",
"commit": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"phi0_worktree": "/Users/bytedance/Projects/cli",
"lineage": [
"phi0",
"a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"557349b40feb359bb791749a37571d59edb7e72e",
"53194d7a111df326cc078b633f43587225bd0132"
]
}

View File

@@ -0,0 +1,35 @@
{
"1": {
"score": 1.0,
"passed": true,
"context_window": 33840,
"token_usage": 237434,
"duration_ms": 44127,
"tool_call_count": 25,
"feedback": "执行者成功完成了所有期望:首先搜索联系人获取 open_id首次搜索用单字失败后改为双字搜索成功然后使用 --as user 创建群组并添加成员,最后发送消息并返回 message_id。整个流程正确使用了等效的 `--as user` 身份,符合用户「使用我的身份」的要求。验证结果确认所有操作均已生效。",
"from_round": 3,
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 1.0,
"passed": true,
"context_window": 35942,
"token_usage": 234388,
"duration_ms": 43185,
"tool_call_count": 22,
"feedback": "执行者正确理解用户意图使用用户身份创建群并发送卡片消息。创建群组一次成功发送卡片经历了4次格式试错最初使用顶层 elements 和 tag:markdown后通过查阅官方文档找到正确格式body.elements + div + lark_md最终成功发送并返回 message_id。试错后自行纠正符合评判原则不构成判罚依据。\n- {'reason': '建议在 lark-im-messages-send.md 中增加飞书 interactive card 的标准格式示例,特别是 2.0 schema 下的 body.elements 中使用 div + lark_md 的正确写法,减少 AI 试错成本'}\n- {'reason': '建议 CLI 在遇到 230099 卡片格式错误时,尝试解析并返回更具体的字段级错误提示(如提示 \"elements 应在 body 内\" 或 \"tag:markdown 不被支持\"),帮助 AI 更快定位问题'}",
"from_round": 3,
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
}
}

View File

@@ -0,0 +1,35 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 1.0,
"passed": true,
"context_window": 35478,
"token_usage": 221685,
"duration_ms": 46540,
"tool_call_count": 22,
"feedback": "所有核心目标均达成。执行者经历了两次试错shell 引号问题、@file 语法不支持但均自行修正并成功完成任务符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}",
"from_round": 2,
"from_candidate": "557349b40feb359bb791749a37571d59edb7e72e"
}
}

View File

@@ -0,0 +1,35 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 0.6,
"passed": true,
"context_window": 37942,
"token_usage": 251669,
"duration_ms": 45769,
"tool_call_count": 23,
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明对于需要用户授权的操作如果用户明确说「不需要确认」Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
}
}

View File

@@ -0,0 +1,35 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 30086,
"token_usage": 292379,
"duration_ms": 51004,
"tool_call_count": 32,
"feedback": "Agent 行为完全正确:选择 user 身份符合需求(用户要求\"使用我的身份\"),认证缺失时正确执行 split-flow 授权流程,路径错误后自行纠正。任务未完成源于用户未完成二维码授权(环境因素),非 agent 能力缺陷。所有期望均因 blocked_by_env 而 PASS。\n- {'reason': '**防御性设计**:在发起授权前,可先检查 `lark-cli auth status` 的 user.identity.status若为 missing 则主动告知用户\"当前用户身份未授权,我先帮你发起授权\",减少用户在看到认证错误后的困惑。'}\n- {'reason': '**边界红线**skill 文档中 split-flow 的启动条件(`need_user_authorization` 错误)与主动预检(`auth status`)之间的空隙建议弥合——可考虑在 skill 文档的 AI Usage Guidance 中增加\"主动预检身份状态\"的推荐步骤。'}\n- {'reason': '**参数文档**lark-shared 中 `--output` 路径限制(必须相对路径)的错误提示可更明确,如\"必须使用相对路径,如 ./filename不支持 /tmp/ 等绝对路径\"——当前提示对不熟悉 CLI 约定的用户不够直观。'}",
"from_round": 0,
"from_candidate": "phi0"
},
"2": {
"score": 0.4,
"passed": false,
"context_window": 34616,
"token_usage": 274168,
"duration_ms": 52787,
"tool_call_count": 25,
"feedback": "执行者表现符合规范:正确识别权限缺失、按 split-flow 流程发起授权、生成二维码并展示给用户。但用户未在执行期间完成扫码授权,导致所有核心业务目标(群聊搜索、消息筛选、转发、@通知)均未完成。这是典型的外部环境阻塞(用户交互依赖),不属于 agent 能力缺陷。执行者的错误处理和流程遵循均正确。\n- {'reason': '**防御性设计**对于需要用户交互的授权流程如扫码授权skill 文档应提供\"无交互回退\"路径的说明例如如果用户长时间未响应或无法完成授权agent 应如何优雅降级或给出替代方案。'}\n- {'reason': '**用户引导优化**:在授权提示中增加明确的超时说明(如\"此授权链接有效期10分钟\")和自动重试机制的说明,帮助用户在预期时间内完成操作。'}\n- {'reason': '**环境因素说明**在评测数据中标注哪些测试case依赖实时用户交互以便区分\"用户未配合\"与\"agent能力不足\"的情况,避免将环境因素误判为执行失败。'}",
"from_round": 0,
"from_candidate": "phi0"
},
"3": {
"score": 0.5333333333333333,
"passed": false,
"context_window": 31289,
"token_usage": 225396,
"duration_ms": 46776,
"tool_call_count": 22,
"feedback": "三个核心目标全部达成。user 身份因未授权阻断属于环境因素blocked_by_envbot 身份成功创建群并发送卡片消息。所有返回的 chat_id 和 message_id 均已验证存在。\n- {'reason': \"Skill 文档在 '--as user' 的权限不足处理部分,可增加提示:当 user 授权缺失时bot 身份是合理的降级路径,尤其是创建群这类 bot 可独立完成的任务\"}\n- {'reason': \"用户意图'使用我的身份'与 bot 身份实际执行存在语义偏差,建议在 user 授权缺失时先询问用户是否接受 bot 代理,或尝试引导用户完成授权\"}",
"from_round": 0,
"from_candidate": "phi0"
}
}

View File

@@ -0,0 +1,67 @@
[
{
"case_id": "2",
"case_label": "CLI_核心评测_015",
"verdict": "FAIL",
"token": 34616,
"duration_ms": 52787,
"tool_calls": 25,
"cmd_attempts": 5,
"cmd_failures": 3,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md reach=1.0 调用前已读;失败在上游 user 授权,非内容触达问题)",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成QR 无人扫),+chat-search --as user 返回 token_missing定位群/转发/@ 全部 blocked驱动该行为的授权流程文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻正文 5777 tok 是 T1 可控热点。",
"doc_fixable_at_T1": false,
"token_hotspot": "运行时冗余清单常驻lark-im SKILL.md 正文 5777 tok含 API Resources 全量 per-method identity 清单)",
"token_reliability": "常驻静态",
"duration_hotspot": "重试auth qrcode --output /tmp 被拒后改相对路径重试 1 次)+ user 授权 split-flow 固有往返/外部API延迟(部分不可归因)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "SKILL.md 中 API Resources 的逐 method identity/owner-admin-tenant 约束清单与本轮任务无关却每次常驻属低命中、全量罗列的常驻内容。effect 不在 T1 可修。"
},
{
"case_id": "3",
"case_label": "CLI_核心评测_080",
"verdict": "FAIL",
"token": 31289,
"duration_ms": 46776,
"tool_calls": 22,
"cmd_attempts": 5,
"cmd_failures": 3,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md + chat-create.md + messages-send.md 调用前已读;建群仍因 user 授权 blocked",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成,+chat-create --as user 返回 token_missing建群即 blocked建卡片/发卡片无法进行;驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。本题 token 最重:读取 Skill 占 49.6%chat-create 3062 + messages-send 5367+ SKILL.md 常驻 5722。",
"doc_fixable_at_T1": false,
"token_hotspot": "按需 reference 偏大messages-send.md 5367 + chat-create.md 3062+ 运行时冗余清单常驻SKILL.md 5722messages-send.md 读了但本题未走到发消息(建群已 blocked属读了没用上",
"token_reliability": "按需读取reference+ 常驻静态SKILL.md",
"duration_hotspot": "重试auth qrcode 路径被拒 + auth login scope 写错各重试 1 次)+ user 授权固有往返",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "messages-send.md / chat-create.md 单文件偏大按需读取时仍是大块SKILL.md 常驻正文偏重。本题为 token 轴杠杆最高的题。effect 不在 T1 可修。"
},
{
"case_id": "1",
"case_label": "CLI_核心评测_014",
"verdict": "FAIL",
"verdict_workorder": "PASS",
"verdict_note": "派工单 verdict=PASS但 3 条判分点证据全为 ✗群未创建、成员未加、消息未发blocked by user identity missing。归因按判分点证据当 FAIL 处理。",
"token": 30086,
"duration_ms": 51004,
"tool_calls": 32,
"cmd_attempts": 10,
"cmd_failures": 6,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md reach=1.0#8 跑了 +chat-create --help 成功;失败在 user 授权与跨域 contact 查询)",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成;先查联系人切到 lark-contact、contact +search-user --as user 同样 token_missing/exit3回到 +chat-create 前已被 user 授权 blocked驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻 5724 tok 是 T1 可控热点。",
"doc_fixable_at_T1": false,
"token_hotspot": "运行时冗余清单常驻lark-im SKILL.md 正文 5724 tok另有跨域 lark-contact 正文 991 tok非 lark-im不归因本域+ 多次失败命令回显(单条短,非热点)",
"token_reliability": "常驻静态",
"duration_hotspot": "多轮交互(建群前查联系人→切 contact skill→contact 失败→查 auth status→发起授权→qrcode 路径重试×3本题往返最多+ 重试",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "SKILL.md 常驻正文偏重失败链路user 授权 + 跨域 contact的驱动/约束文档不在 lark-im、本轮不可改。effect 不在 T1 可修。"
}
]

View File

@@ -0,0 +1,24 @@
{
"1": [
"auth login",
"auth qrcode",
"auth status",
"contact +search-user",
"contact resolve \"傅一铭\"",
"contact resolve \"傅二铭\"",
"im +chat-create"
],
"3": [
"auth login",
"auth qrcode",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"im +chat-messages-list",
"im +chat-search",
"im +messages-search"
]
}

View File

@@ -0,0 +1,29 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}"
},
"3": {
"score": 0.6,
"passed": true,
"context_window": 37942,
"token_usage": 251669,
"duration_ms": 45769,
"tool_call_count": 23,
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明对于需要用户授权的操作如果用户明确说「不需要确认」Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}"
}
}

View File

@@ -0,0 +1,97 @@
# Round 1 归因(候选模块见 candidate_modules模块由 candidate-writer 根据诊断和 reach 选定)
> 目标objective.json**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化token 与 duration 是并列成本杆。tier=T1仅可改 `skills/lark-im/**`。
> 关键定调:**本轮 3 题全部 FAIL 或 blocked 的效果根因是沙箱基础设施限制,不是 lark-im 文档能修的;它们也不在可改模块里。** 因此本轮的真实抓手是 token 轴(每次运行常驻 + 误导性内容),不是去「修挂题」。下面分维度说明。
## 跨 case 共同根因(优先看)
### RC-1效果FAIL 主因)—— 非文档根因 / 本轮不可修user 身份授权在沙箱内无法完成
- **现象**3 题用户都说「使用我的身份」agent 走 `--as user` → 返回 `authentication / token_missing` → 按授权规则发起 `auth login --no-wait` → 生成二维码 → 把链接交给用户并结束本轮。沙箱里没有真人扫码user 身份永远 `missing`,于是建群/搜群/发消息全部 blocked。三题轨迹高度同构015/080/014
- **行为是被文档「正确」驱动的,不是 agent 乱来**:发起 split-flow 授权、生成二维码、展示链接后交还控制权,这一整套是 `skills/lark-shared/SKILL.md`L17、L72105明确 MUST 的流程。agent 严格照做。
- **归因落点**:根因在**沙箱无法完成交互式 user 授权**(基础设施)+ 驱动该行为的授权流程文档在 `lark-shared`
- **为什么本轮不可修(重要,给 candidate-writer 的边界)**
1. `lark-shared/SKILL.md` **不在 candidate_modules**objective.modules 只含 `skills/lark-im/**`),无权改。
2. 即便能改,沙箱不能扫码这一物理限制不是文档能绕过的——这是环境,不是内容缺失。
3. **不要试图通过让 agent 改走 `--as bot` 来「修绿」**用户显式要「我的身份」grader 判分点也写「使用当前用户身份创建」。改路由去 bot 是 reward-hack绕过判分点、语义回退不是合法的成功率修复。reviewer 会据此 FAIL。
- **axis=效果**,但标注为**无文档根因 / 本轮不改**。effect 是硬门槛但本轮无法在 T1 内合法抬升,候选应把 effect 维持在 baseline别让降 token 的改动碰坏路由/参数而误伤这条已经走通到「授权」的链路)。
### RC-2token本轮真正的抓手—— 每次运行常驻的 lark-im 注入正文偏重
- **现象**:每题固定加载两块 lark-im 正文,且**与该题任务大多无关**
- `lark-im`**Skill 列表注入**(系统级 description 段4,612 tok015 占 28.2%、080 占 18.8%、014 占 25.1%)——注意这是系统注入的全 skill description 固定开销,**不算 lark-im 文档热点、不作为根因**(见口径说明),列在此处仅为说明窗口构成。
- `lark-im`**SKILL.md 正文**(经 Skill 工具加载reach=1.0):约 **5,7225,777 tok/题**,三题都常驻。这是 `skills/lark-im/SKILL.md`**在可改模块内,是 token 轴的头号可控热点**。
- **SKILL.md 里有大量与本轮任务无关的常驻清单**`## API Resources`L114+)逐条列了 chats / chat.members / messages / reactions / threads / image / pin / feed 等**每个 resource.method 的 identity 规则与 owner/admin/tenant 约束**L123190几十行。本轮 3 题只用到建群、搜群/搜消息、发消息、转发、@——绝大多数 method 行每次运行都被加载却从不被用到。这是典型「每次运行都会加载的运行时冗余清单常驻」。
- **可信度=常驻静态**SKILL.md 经 Skill 工具每题必加载reach=1.0tiktoken 可测、跨题稳定5,722/5,724/5,777 三题一致)。这是降 token 最稳的发力点。
- **axis=token**。文档位置:`skills/lark-im/SKILL.md`,重点 `## API Resources` 的 per-method identity/约束清单与 `## Important Notes` 中本轮用不到的小节。
### RC-3token次级抓手—— 按需 reference 体积偏大,且只在用到的题里计入
- **现象**080 读了 `chat-create.md`(3,062 tok) + `messages-send.md`(5,367 tok),两块 reference 合计 8,429 tok占该题 visible 的 34.4%。014 也读了 chat-create.md。
- **判据**reachchat-create=0.667、messages-send=0.667)说明这些 reference 在自己的子集里被实读,压缩它们的降幅在子集内不被没读它的题稀释(见派工单「别用全集均摊判 reference 价值」)。`messages-send.md` 单文件 5,367 tok 尤其大。
- **可信度=按需读取**:只在实际 Read 该 reference 的题里计入,不能按全集均摊。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md``lark-im-chat-create.md`
### RC-4duration弱信号需复现—— `auth qrcode --output "/tmp/..."` 被拒后反应式重试
- **现象**3 题都先用 `--output "/tmp/lark_auth_qr.png"`(或 `/workspace/agent-cwd/qrcode.png`)→ 报 `validation / invalid_argument: unsafe output path` → 改用相对路径 `./xxx.png` 重试成功。每题多 12 个往返。
- **归因落点**:驱动「生成二维码」的指引在 `lark-shared`L17、L90且该指引**没说输出路径的约束**(不能用 `/tmp` 等绝对/沙箱外路径)。这是「报错没指下一步 + 文档没写约束」的耗时根因。
- **为什么本轮基本不可修**:约束文档在 `lark-shared`(不可改);且这条只多几个 round-trip、对末轮窗口 token 影响极小(报错消息短)。
- **可信度**:耗时波动大,单题不算数;但此模式**3 题一致复现**,作为 duration 旁证可信度提升。不过它仍**不在 T1 可改范围**,仅记录。
- **axis=duration**,标注为**驱动文档不可改lark-shared**。
## 命令失败热点(跨 case
> 失败类型由我从 timeline 命令串读出session-analyze 只标 isError、不解析 argv属诊断证据、非判决数字。
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|---|---|---|---|---|
| `im +chat-search` | 2 | 1 (015) | `--as user` → token_missing | user 身份未授权(沙箱限制);非内容错误 |
| `im +chat-create` | 1 | 1 (080) | `--as user` → token_missing | 同上 |
| `contact +search-user` / `contact resolve` | 4 | 1 (014) | exit 2/3user 身份 / 命令不存在) | 跨 skilllark-contact非 lark-im 内容 |
| `auth qrcode --output /tmp/...` | 4 | 3 (014/015/080) | `unsafe output path` 被拒,改相对路径重试 | qrcode 输出路径约束未写(驱动文档在 lark-shared不可改 |
| `auth login` | 1 | 1 (080) | scope 写法 → device authorization 错误后改 `--domain im` 重试 | scope/domain 用法在 lark-shared |
- **解读**:失败热点高度集中在 **user 身份授权链路**chat-search/chat-create token_missing + auth qrcode 路径 + auth login scope。这一整条链路的驱动与约束文档都在 `lark-shared`**不是 lark-im 文档能修的**。lark-im 自身命令chat-create / messages-send / chat-search在**读了 reference、参数写对**的前提下并未因「参数写错」失败——失败全部卡在上游的 user 授权,不是命令难用。**这意味着没有 lark-im 侧的「报错/输出整形」工单**。
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
> 对每条预期该读的 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash不在 reach 里)。
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错 | 主导态 → 改动方向 |
|---|---|---|---|---|---|
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | 3 | ③ 调用前已读,仍卡授权 → **非触达问题**;且不可改 |
| `lark-im-chat-create.md` | 0.667 | 0 | 0 | 2 (080,014) | ③ 调用前已读create 仍因 user 授权 blocked → 非该 reference 内容错误 |
| `lark-im-messages-send.md` | 0.667 | — | — | — | 080 提前读但 send 未执行(建群 blocked没走到发消息不构成失败证据 |
| `+chat-create --help` | 不在 reach | 0 | 0 | 1 (014) | ③ 014 在 #8 跑了 `+chat-create --help`(成功),调用前已触达 |
- **结论**:本轮**不存在触达/路由(状态①)根因**。三题都在调用前读到了 SKILL.mdreach=1.0)、读到了相关 reference、甚至跑了 `--help`。失败发生在**内容已触达之后的上游授权环节(状态③语义,但根因是环境而非文档内容错)**。
- **对 candidate-writer 的含义****不要把 RC-1 误判为①而推「前置授权说明」**——内容已经读到了,前置救不了沙箱不能扫码。前置类改动在本轮对 effect 无效,只会增 token与目标背道而驰。
## 差距台账复盘
-round 1`discard-ledger.json` 为空)。
## 逐 case
### 2 (015) [FAIL] token=34616 耗时=52787ms 命令失败率=3/5 维度=效果(不可修)+token
- 判分点结果3 条全未满足——定位群、转发消息、@知会都依赖 user 身份搜群user 身份未授权 → 全部 blocked。
- 命令失败3/5。2× `+chat-search --as user` → token_missing1× `auth qrcode --output /tmp` → unsafe output path改相对路径成功
- 可发现性时序SKILL.md 调用前已读reach=1.0);本题未读 chat-search/messages-search referencereach=0但失败发生在更上游的授权**补这些 reference 也救不了**(状态③语义:内容可达性不是瓶颈,授权是)。
- token 归因SKILL.md 正文 5,777 tok常驻静态35.3%+ 系统级 Skill 列表注入 4,612 tok固定开销不归因。本题未读大 reference故 token 主来源就是常驻 SKILL.md 正文。
- 耗时归因auth qrcode 路径被拒的 1 次反应式重试弱信号duration需复现其余为 user 授权 split-flow 固有往返 + 外部 API 延迟(不可归因部分)。
- 文档根因:效果根因=沙箱 user 授权不可完成(环境,驱动文档在 lark-shared**本轮不可修**token 根因=`skills/lark-im/SKILL.md` 常驻正文偏重(**可修T1 抓手**)。
### 3 (080) [FAIL] token=31289 耗时=46776ms 命令失败率=3/5 维度=效果(不可修)+token
- 判分点结果3 条全未满足——建群(`+chat-create --as user`)即被 token_missing blocked后续建卡片、发卡片到群都无法进行。
- 命令失败3/5。1× `+chat-create --as user` token_missing1× `auth login --scope "..."` device authorization 错误(改 `--domain im` 重试1× `auth qrcode --output /tmp` unsafe path改相对路径成功
- 可发现性时序:调用前读了 SKILL.md + chat-create.md + messages-send.md全部状态③调用前已触达建群仍因 user 授权 blocked**非 reference 内容错误**。
- token 归因:**本题 token 最重,读取 Skill 占 49.6%**——chat-create.md 3,062 + messages-send.md 5,367 = 8,429 tok按需读取 SKILL.md 正文 5,722 tok常驻静态。这是 RC-2 + RC-3 同时发力的题。messages-send.md 提前读但本题根本没走到发消息(建群已 blocked属「读了没用上」的浪费。
- 耗时归因auth qrcode 重试 + auth login scope 写错重试,各 1 次反应式往返弱信号duration需复现
- 文档根因:效果=沙箱 user 授权不可修token=SKILL.md 常驻正文 + 两个偏大 reference**可修T1 抓手;本题杠杆最高**)。
### 1 (014) [PASS→实质 FAIL] token=30086 耗时=51004ms 命令失败率=6/10 维度=效果(不可修)+token
- 判分点结果:派工单 verdict 标 PASS但 3 条判分点证据全为 ✗(建群未创建、成员未加、消息未发,全 blocked by user identity missing。**实质是 FAIL**PASS 系上层聚合口径差异,归因按判分点证据处理。
- 命令失败6/10最高`contact resolve` ×2 exit 2命令形态不对走的是 lark-contact 域);`contact +search-user --as user` ×2 exit 3user 未授权);`auth qrcode --output 绝对路径` ×2 unsafe path第三次相对路径成功
- 可发现性时序:#7 调用前读 SKILL.mdreach=1.0#8 跑了 `+chat-create --help`(成功,状态③,调用前已触达建群用法);随后为查联系人切到 lark-contact skill。失败集中在 user 授权与跨域 contact 查询,**非 lark-im 内容可达性问题**。
- token 归因SKILL.md 正文 5,724 tok常驻静态31.1%+ 系统 Skill 列表注入 4,612 tok固定开销不归因+ lark-contact 正文 991 tok跨域非 lark-im。lark-cli 命令累计 2,577 tok14%),含多次失败回显,但单条都短、非热点。
- 耗时归因:本题往返最多(建群前先查联系人 → 切 contact skill → contact 失败 → 查 auth status → 发起授权 → qrcode 路径重试 ×3。多为 user 授权链路 + 跨域查联系人固有串行 + 反应式重试duration 弱信号,需复现)。
- 文档根因:效果=沙箱 user 授权 + 跨域 contact 不可用环境不可修token=`skills/lark-im/SKILL.md` 常驻正文(**可修T1 抓手**)。
## 给 candidate-writer 的收口(不含具体改法)
- **唯一在 T1 内可合法发力的轴是 token**,对应 RC-2SKILL.md 常驻正文3 题全命中、最稳)与 RC-3chat-create/messages-send reference 偏大080 命中)。两者方向一致(减体积),可作为本轮候选的目标轴。
- **effect 不可在本轮 T1 内合法抬升**RC-1 环境限制 + 驱动文档在不可改的 lark-shared。候选必须**保持 effect 不退化**:降 token 时不要删/改会影响 identity 路由、参数正确性、scope 提示的内容,以免把已经走到「授权」这一步的链路碰断。
- **方向冲突提示**RC-1 若有人想「补授权说明帮 agent 过」与目标(降 token方向相反且对沙箱无效——**明确不要做**。RC-2/RC-3减体积与目标同向无冲突。
- **缺失信息doc_fix_hint 语气,非药方)**SKILL.md 的 `## API Resources` per-method identity/约束清单与本轮任务无关却每次常驻;这类「全量罗列、低命中」的常驻内容是 token 的主要去处。messages-send.md / chat-create.md 单文件偏大,按需读取时仍是大块。
- **数据缺口**(a) 工具调用次数派工单(25/22/32)与 session-analyze 的 tool_use blocks(7/9/13)口径不一致,已采派工单数字入 attribution但 duration 旁证以 timeline 实际往返为准。(b) duration 根因RC-4单轮不足以定论需多轮/多次复现;且其驱动文档在 lark-shared 不可改。(c) 014 派工单 verdict=PASS 与判分点证据全 ✗ 冲突,归因按判分点证据当 FAIL 处理。

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,222 @@
{
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
}

View File

@@ -0,0 +1,15 @@
{
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/SKILL.md",
"tier": "T1",
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope非语义丢失而是迁回文档本就强制查询的权威源SELECTION 层路由Identity-and-Token-Mapping、Shortcuts 表字节未动L1-109 完全一致23 个 reference 链接集合改动前后完全相同reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效token 4960→2986-39.8%tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
"dimensions": {
"reward_hack": {"pass": true, "evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿Identity-and-Token-Mapping 路由块L38-42字节未动符合 diagnosis「保 effect 不追 effect」的要求"},
"semantic_regress": {"pass": true, "evidence": "实测无承重内容丢失lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope如 im:message.urgent删块全部可在运行时由 schema 取回23 个 reference 集合改动前后完全相同reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"},
"token_shift": {"pass": true, "evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"},
"contract_break": {"pass": true, "evidence": "T1 无对外契约删除目标method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象新指针同时覆盖 schema+lark-shared 报错流程语义23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"},
"devguide": {"pass": true, "evidence": "对照 review-rubric 优化红线两维semantic_regress / contract_break均无触犯信息归属正确method/scope 索引应交给 schema/--help、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared结构与链接合规"},
"single_root_cause":{"pass": true, "evidence": "diff 仅服务 RC-2裁常驻 USAGE 索引),未捆 RC-3reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"}
}
}

View File

@@ -0,0 +1,404 @@
{
"round": 1,
"status": "admitted",
"parent_id": "phi0",
"parent_worktree": "/Users/bytedance/Projects/cli",
"child_worktree": "/Users/bytedance/Projects/cli",
"base_commit": "040ef17eae0ac350c556081544793aacce675e90",
"module": "skills/lark-im/SKILL.md",
"candidate_modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"module_reach": {
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
},
"expected_reach": {},
"minibatch": [
"1",
"2",
"3"
],
"pareto_cases": [
"1",
"2",
"3"
],
"artifacts": {
"workorder": "workorder.md",
"diagnosis": "diagnosis.md",
"attribution": "attribution.json",
"strategy": "strategy.md",
"review": "review.json",
"trend": "trend.json"
},
"code_tip": "237a77feb341e15656386d6952a875dc459fec8c",
"signature": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"tier": "T1",
"intent": "将 SKILL.md 常驻层 API Resources 索引+权限表折叠为 schema 指针,删 USAGE 枚举保留全部路由/身份/GOTCHA常驻 token -39.8%",
"target_axis": "token",
"changed_files": [
"skills/lark-im/SKILL.md"
],
"decision_basis": {
"type": "module",
"module": "skills/lark-im/SKILL.md"
},
"decision_cases": [
"1",
"2",
"3"
],
"review": {
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/SKILL.md",
"tier": "T1",
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope非语义丢失而是迁回文档本就强制查询的权威源SELECTION 层路由Identity-and-Token-Mapping、Shortcuts 表字节未动L1-109 完全一致23 个 reference 链接集合改动前后完全相同reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效token 4960→2986-39.8%tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
"dimensions": {
"reward_hack": {
"pass": true,
"evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿Identity-and-Token-Mapping 路由块L38-42字节未动符合 diagnosis「保 effect 不追 effect」的要求"
},
"semantic_regress": {
"pass": true,
"evidence": "实测无承重内容丢失lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope如 im:message.urgent删块全部可在运行时由 schema 取回23 个 reference 集合改动前后完全相同reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"
},
"token_shift": {
"pass": true,
"evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"
},
"contract_break": {
"pass": true,
"evidence": "T1 无对外契约删除目标method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象新指针同时覆盖 schema+lark-shared 报错流程语义23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"
},
"devguide": {
"pass": true,
"evidence": "对照 review-rubric 优化红线两维semantic_regress / contract_break均无触犯信息归属正确method/scope 索引应交给 schema/--help、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared结构与链接合规"
},
"single_root_cause": {
"pass": true,
"evidence": "diff 仅服务 RC-2裁常驻 USAGE 索引),未捆 RC-3reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"
}
}
},
"child_k": 5,
"eval_trace": null,
"retro": {
"cause": "已入池",
"noise_borderline": false,
"summary": "越带入池,无需复盘补发"
},
"retro_sessions": [
{
"case": "1",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 30086,
"child": 34270,
"gain": "反向",
"pass_delta": null
},
{
"case": "2",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 34616,
"child": 47116,
"gain": "反向",
"pass_delta": "修好"
},
{
"case": "3",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 31289,
"child": 37942,
"gain": "反向",
"pass_delta": "修好"
}
],
"verdict": "admitted",
"ci": null,
"new_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"decision": {
"parent_success": 0.3333333333333333,
"child_success": 1.0,
"parent_score": 0.5111111111111111,
"child_score": 0.6666666666666666,
"score_saved": 0.15555555555555556,
"score_threshold": 0.09532271373123208,
"parent_token": 31997.0,
"child_token": 39776.0,
"saved": -7779.0,
"threshold": 4532.708313776408,
"parent_duration": 50189.0,
"child_duration": 68024.66666666667,
"dur_saved": -17835.66666666667,
"dur_threshold": 4899.200953624988,
"dur_margin": 1.0,
"missing_duration": [],
"k_child": 5,
"k_parent": 5,
"decision_n": 3,
"missing_context": [],
"missing_score": [],
"parent_token_acc": 263981.0,
"child_token_acc": 379441.6666666667,
"phi0_score": 0.5111111111111111,
"eff_margin": 1.0,
"parent_token_full": 31997.0,
"child_token_full": 39776.0,
"saved_full": -7779.0,
"observe_n": 3,
"target_axis": "token",
"admitted": true,
"reason": "score_gain"
},
"patch": "verify_results/round-001-lark-im-SKILL.patch"
}

View File

@@ -0,0 +1,44 @@
# Round 1 候选策略(模块=skills/lark-im/SKILL.md, tier=T1, 主指标=token
## 根因与选择
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|---|---|---|---|---|---|---|
| RC-2SKILL.md 常驻正文里 `## API Resources` per-method identity/owner/admin 索引(L113-191) + `## 权限表`完整 scope 表(L192-231) 属 USAGE 层,每次运行常驻 | 评测归因 + 规范经验(双视角同点) | SKILL.md(1.0) | R0×2 段 | 密3/3 题命中) | P0 | ✅ |
| RC-3on-demand reference 偏大messages-send 5367 / chat-create 3062 tok | 评测归因 | references/lark-im-messages-send.md(0.667)、chat-create.md(0.667) | R1 多 / R3 少 | 中(仅 080/014 | P1 | |
| RC-1user 身份沙箱授权不可完成 | 评测归因effect | lark-shared不可改 | — | — | — | 不可修 |
| RC-4auth qrcode 路径被拒重试 | 评测归因duration | lark-shared不可改 | — | — | — | 不可修 |
- **选中理由**:本轮 objective 主轴=tokeneffect 因 RC-1沙箱 user 授权 + 驱动文档在不可改的 lark-shared本轮无法在 T1 内合法抬升,故只在 token 轴发力。RC-2 是 reach=1.0 的头号可控热点——3 题全命中、tiktoken 稳定5,722/5,724/5,777、每次运行都付费。RC-3 是 reach=0.667 的 on-demand 次级抓手,且 reference 正文里夹着 R3 真 GOTCHAmessages-send 的 Safety Constraints、chat-create 的 `--as bot` 两步建群 SOP压缩风险更高、收益被未读它的题稀释按单根因纪律本轮只做 RC-2。RC-1/RC-4 落 lark-shared越界即被 scope check 拒,且沙箱物理限制非文档可绕——不碰。
- **选模块理由**SKILL.md reach=1.0(经 Skill 工具每题必加载),是 RC-2 的唯一承载。改动全部落在它内部coherent不触任何别的 skill。
- **规范经验源补注**:双视角在同一处汇合——
- 视角②annotation`skill-annotations.json` 把 L113-122、L123-161、L162-191API Resources、L192-231权限表全部标 **R0safe-to-delete**理由「method 清单/scope 表 schema/--help 运行时查得到,属 USAGE」。
- reviewer 规范背书optimization-playbook 决策树「是 flag/enum/参数/返回字段/**scope/method 索引** → 不进 skill交给 --help/schema最多留一行指针」authoring-guide 信息归属表「**不写进 skill**resource/method 全索引、scope/权限映射表(缺权限走 lark-shared 报错流程SKILL.md 锚点 6「`--help`/schema 管 USAGEreference 只留 gotcha」。三处独立指向同一删除对象。
- coverage3/3 题都加载 SKILL.mdtoken 收益在常驻层可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),不是难裁的拟合型改动。
## 改了什么(逐处)
- `skills/lark-im/SKILL.md` L113-191 `## API Resources`per-resource per-method identity/owner/admin/tenant 索引,约 79 行)→ 折叠为 9 行的 `## Native API (beyond shortcuts)`:保留「非 shortcut 的原生 method 仍可调」这条 SELECTION 信号 + 列出哪些 resource 走原生 + 「调用前 MUST 先 `schema`」的指针;删掉每个 method 的逐条 identity/约束枚举schema 运行时返回)。
- `skills/lark-im/SKILL.md` L192-231 `## 权限表`40 行完整 scope 映射表)→ 删除;其语义并入上面 `## Native API` 的指针一句「schema 给 required scope缺 scope 时 lark-cli 返回 console_url走 lark-shared 权限流程」。
- `skills/lark-im/SKILL.md` Shortcuts 速查表新增 2 行:`reactions.*``references/lark-im-reactions.md``feed.groups.*``references/lark-im-feed-groups.md`。**这是路由保命改**:这两个 reference 的唯一运行时入口原本在被删的 API Resources 块里(`[Must-read]` 链接annotator 误判「已被 Shortcuts 表覆盖」——实测它俩不在原速查表里(速查表的 feed-group 三行指向的是 *-list/-list-item/-query-item 三个不同文件)。不补这 2 行 = 删 reference 链接 = 该 reference reach 永久归 0、路由断裂。
## 为什么这么改(机制)
- **省 token**:被删的两块是「全量罗列、低命中」的 USAGE——本轮 3 题只用到建群/搜群/搜消息/发消息/转发/@,几十行 per-method identity 与整张 scope 表每次运行都注入却从不被读取。删后 Agent 仍能:(1) 经 SKILL.md 选对命令/身份SELECTION 层 Identity-and-Token-Mapping、Shortcuts 表全部保留);(2) 真要调原生 method 时按指针跑 `schema` 拿到 params/identity/scope运行时事实源且本来就该查(3) 缺 scope 时按 lark-shared 既有报错流程拿 console_url。即「删了 Agent 还做得对吗?做得对就删」(锚点 2
- **不碰 effect**:保留全部 SELECTION 层路由——CRITICAL 先读 lark-sharedL13、Identity and Token Mappinguser/bot↔tokenR3、完整 Shortcuts 速查表、各域特有 GOTCHAbot 取不到 sender name、enrichment/download 契约、flag/feed-shortcut 概念)。没有改 identity 路由、没有改参数正确性、没有删 scope 提示语义(指针仍指向 schema+lark-shared 流程。已经走到「user 授权」这一步的链路不会被碰断。
- **规范背书**optimization-playbook §2 决策树 + authoring-guide 信息归属表 L95 + SKILL.md 锚点 6三处独立判定 method 索引/scope 表「不进 skill最多留一行指针」——本改动正是把两块 USAGE 折叠成指针。
## 预期效果
- **成功率effect 硬门槛)**:不退化。删除的是 USAGE 枚举,保留全部 SELECTION/路由/身份/GOTCHA。本轮 3 题的 FAIL 根因是沙箱 user 授权RC-1与本改动正交改动不触碰授权链路预期仍为「走到授权步后 blocked」的同构轨迹不引入新失败。
- **context分两层**
- (1) **静态字数差**SKILL.md 从 4,960 → 2,986 tokcl100k_basereviewer 脚本实测),**-1,974 tok / -39.8%**;落入金标杆带(中位数 ~2,400、lark-shared 2,709接近上一轮 IM 治理目标 2,040。
- (2) **每题运行时 context 方向**3 题全部下降,且降幅≈静态差——因为 SKILL.md reach=1.0 每题必全量加载,常驻层减重直接等额传导到每题 visible评测里 SKILL.md 正文 5,722-5,777 tok/题 → 预计降约 2k/题)。**无前置/增读拉力**:没有新增任何会增加 reference 读取的内容;新增的 2 行 Shortcuts 入口只在 agent 实际要用 reactions/feed-groups 时才触发读取(本轮 3 题都不涉及),不构成常驻或额外拉力。与 directiontoken↓一致无张力。
- **可裁性**token 收益在常驻层、可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),非难裁的拟合型改动;无覆盖敞口。
## 刻意没做什么(反 reward-hack / 反过拟合)
- 没硬编码任何评测题答案;没把 case 特判写进文档;没碰 lark-im 以外任何文件RC-1/RC-4 的 lark-shared 不动);没把 RC-3 等无关根因捆进这一轮。
- **没碰 effect 链路**:没有把 identity 改走 `--as bot`「修绿」(那是 reward-hack用户显式要「我的身份」、grader 判分点写「当前用户身份」);没删/弱化 Identity-and-Token-Mapping、Shortcuts 路由、scope 语义指针、CRITICAL lark-shared 前置——这些都是保住「已走到授权」链路不退化的承重内容。
- **没删 reference 入口**:被删块里两个 referencereactions/feed-groups的唯一入口已迁入 Shortcuts 速查表reach 不归零、路由不断裂(纠正了 annotator「已覆盖」的误判
- **没做输出裁剪、没碰命令行为**T1 docs-only且 playbook 红线:输出裁剪须独立设计验证)。
- **没补「前置授权说明」**:诊断证据显示 3 题调用前都已读到 SKILL.mdreach=1.0),失败在更上游的沙箱授权(状态③语义、根因是环境),前置救不了且只会增 token与目标背道——明确不做。
- 这是「减体积」改动、与评测错误分布无拟合关系不存在朝错误分布过拟合的敞口lite 无 sealed 也不构成隐患。
## 签名
- signature: a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649egit diff skills/lark-im/SKILL.md 内容哈希) tier: T1

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,35 @@
# Round 1 归因派工单parent=phi0模块未定由 candidate-writer 据诊断点名)
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer+ 逐题结构化 `attribution.json`(给 dashboard。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置正该选来修——不是白烧reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3'];其中挂的: ['2', '3']
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
### 2 [FAIL] ctx=34616 (acc=274168) 52787ms tools=25
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 成功定位名为「fusanming_at_openclaw群」的群并获取最近包含「飞豆」关键字的消息。
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」。
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功。
### 3 [FAIL] ctx=31289 (acc=225396) 46776ms tools=22
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
✓ 将该卡片发送到新建群中,预期返回 message_id
### 1 [PASS] ctx=30086 (acc=292379) 51004ms tools=32
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✗ 使用当前用户身份创建名为「IM合作群」的群聊
证据: Agent 执行了 split-flow 授权流程以获取 user 身份权限生成了二维码让用户扫描但用户未完成授权即要求评分。Auth status 显示 'User identity: missing',群聊未被创建。
✗ 将傅一铭和傅二铭加入该群
证据: 依赖群聊创建结果。由于群聊未创建blocked by user identity missing无法添加成员。
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
证据: 依赖群聊创建结果。由于群聊未创建,无法发送消息。

View File

@@ -0,0 +1,65 @@
[
{
"case_id": "1",
"case_label": "CLI_核心评测_014",
"verdict": "PASS",
"verdict_note": "workorder=PASS聚合口径判分点证据 3/3 ✗ → 实质 FAIL按判分点当 FAIL 归因",
"token": 34555,
"token_visible_est": 17364,
"duration_ms": 37000,
"tool_calls": 8,
"cmd_attempts": 7,
"cmd_failures": 5,
"cmd_fail_rate": 0.71,
"discoverability_state": "③ 读了仍卡SKILL.md+chat-create.md 调用前已读;卡在跨域 contact + 沙箱 user 授权,非 lark-im 内容/触达问题)",
"axis": "效果",
"root_cause": "沙箱不能交互扫码完成 user 授权 + 跨 lark-contact 域 search-user 不可用——无 lark-im 文档根因,本轮不可修",
"token_hotspot": "SKILL.md 常驻正文(RC-1) + chat-create.md 按需读取(RC-3本题读了但授权阻断没用上);无 lark-cli 输出离群",
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(chat-create.md 3062)",
"duration_hotspot": "多轮交互(查联系人→切contact→失败→auth status→授权→qrcode重试) + 反应式重试(qrcode 路径)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "无 lark-im 文档可修点(效果根因在环境+跨域lark-im 侧仅 token 减法SKILL.md 常驻、chat-create.md 体积)"
},
{
"case_id": "2",
"case_label": "CLI_核心评测_015",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,全程 bot 身份完成,无授权阻断(推翻 round-1 的 blocked 定调)",
"token": 54568,
"token_visible_est": 43760,
"duration_ms": 125000,
"tool_calls": 16,
"cmd_attempts": 9,
"cmd_failures": 3,
"cmd_fail_rate": 0.33,
"discoverability_state": "① 从没读chat-messages-list.md / messages-search.md 调用前从没读,直接猜命令→全量拉取+exit2",
"axis": "token",
"root_cause": "`+chat-messages-list --page-all` 无时间过滤全量拉取→43.5KB持久化→Read 灌入 22556 tok放大器是 chat-messages-list.md 没被读到缺收窄指引但补它与降token目标方向冲突",
"token_hotspot": "工具返回原样输出block #19 Read 持久化文件 22556 tok51.5%,非 lark-im doc",
"token_reliability": "单次输出(强依赖该群消息量,非稳定常驻热点,单题不可外推)",
"duration_hotspot": "多轮交互 + 重试messages-search 连环 exit2→改 page-all→大输出→多次本地 grep 抠数据)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现;工具调用 16 明显高于 080作旁证",
"doc_fix_hint": "token 黑洞来自工具输出非文档SKILL.md 表对 chat-messages-list 未提示大群应 server-side 收窄——但补此为增内容与降token冲突列观察项不作本轮根因"
},
{
"case_id": "3",
"case_label": "CLI_核心评测_080",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,主动选 bot 身份完成建群+发卡片,零命令失败(推翻 round-1 的 blocked 定调)",
"token": 38009,
"token_visible_est": 21599,
"duration_ms": 47000,
"tool_calls": 6,
"cmd_attempts": 3,
"cmd_failures": 0,
"cmd_fail_rate": 0.0,
"discoverability_state": "③ 读了即用SKILL.md+chat-create.md+messages-send.md 调用前全读到且用上,无触达问题)",
"axis": "token",
"root_cause": "messages-send.md 单文件 5365 tok内部 4 处『选 content flag』语义重叠 + Commands 全形态罗列)+ SKILL.md 常驻 + chat-create.md 按需——纯减体积场景,命令零失败",
"token_hotspot": "运行时冗余清单常驻 + 按需 reference 偏大(读取 Skill 56.4%messages-send.md 5365 + SKILL.md 3751 + chat-create.md 3060",
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(messages-send.md 5365 子集reach0.333、chat-create.md 3060 子集reach0.667)",
"duration_hotspot": "无离群47s 正常建群+发卡片串行,无重试、无写后回查)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "messages-send.md 选型规则在 4 处重复表述、Commands 罗列全部媒体形态SKILL.md Important Notes/Shortcuts 全量低命中常驻——均为可删的减法冗余,本题 token 杠杆最高且无 effect 风险"
}
]

View File

@@ -0,0 +1,27 @@
{
"1": [
"auth login",
"auth qrcode",
"contact +search-user"
],
"3": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-messages-list",
"im +chat-search",
"im +messages-mget",
"im +messages-search",
"im +messages-send",
"im messages forward",
"schema im.messages.forward",
"schema im.messages.search"
]
}

View File

@@ -0,0 +1,11 @@
{
"3": {
"score": 1.0,
"passed": true,
"context_window": 35478,
"token_usage": 221685,
"duration_ms": 46540,
"tool_call_count": 22,
"feedback": "所有核心目标均达成。执行者经历了两次试错shell 引号问题、@file 语法不支持但均自行修正并成功完成任务符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}"
}
}

View File

@@ -0,0 +1,113 @@
# Round 2 归因parent=round-1 已采纳候选 51f2a70e候选模块见 candidate_modules由 candidate-writer 据诊断+reach 点名)
> 目标objective.json**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化token 与 duration 是并列成本杆。tier=T1仅可改 `skills/lark-im/**`。
> 判分点只当「什么算挂」的锚,不抄 grader 药方。
> **本轮 trace = round-1 已采纳候选51f2a70eSKILL.md 已 trim 到约 3,915 tok的行为**,不是 baseline。三题 session 实测已确认 SKILL.md 注入正文为 3,751 tok/题(与 trim 后体积一致round-1 报告的 5,722 tok/题是 trim 前数字,已过期。
## ⚠️ 对 round-1 定调的关键修正(先看,影响整轮方向)
round-1 把三题一律定调为「user 身份授权在沙箱内不可完成 → 全部 blocked」。**实测 trace 推翻了这个 monolith三题行为完全不同只有 1 题真卡授权。**
| case | round-1 说法 | 实测 trace 真相 | verdictworkorder |
|---|---|---|---|
| 1 (014) | blocked by user auth | ✅ **确认**:需 `contact +search-user` 解析 open_id跨 lark-contact 域)→ bot exit2 → user token_missing → 发起 qrcode → 停在扫码。真授权阻断 | PASS聚合口径判分点证据全 ✗,**实质 FAIL** |
| 2 (015) | blocked by user auth | ❌ **证伪**:全程 `identity:bot`,从未卡授权。搜群✓、定位「飞豆」消息✓、转发✓、@傅六铭✓,两次 `messages-send``ok:true`。**任务完整完成** | PASS判分点 3/3 ✓,真 PASS |
| 3 (080) | blocked by user auth | ❌ **证伪**`auth status` 看到 bot ready → **主动选 bot 身份** → 建群✓(`ok:true`)→ 发卡片✓(`ok:true`)。**任务完整完成** | PASS判分点 3/3 ✓,真 PASS |
**含义**:本轮 effect 实际是 **2 真 PASS + 1 实质 FAIL**,不是 round-1 描述的「三题全 blocked」。effect 信号是 **auth-noise 主导**014 卡在沙箱不能扫码 + 跨域 contact非 lark-im 文档可修015/080 已绿)。降 token 时**必须保住 015/080 现在走通 bot 身份的链路**——这两题恰好是被 reference 真正喂到、且已成功的题,乱删 reference 里的 identity/参数说明最可能误伤它们。
## 跨 case 共同根因(优先看;按对 TOKEN 目标的杠杆排序)
### RC-1token头号抓手3 题全命中、最稳)—— SKILL.md `## Important Notes` + Shortcuts 全表常驻,本轮任务低命中
- **现象**SKILL.md 经 Skill 工具每题必加载reach=1.0),实测 3,751 tok/题、三题一致(常驻静态)。但其中大段与本轮 3 题(建群 / 搜群+搜消息+转发+@ / 建群+发卡片)无关:
- `## Important Notes`L3685约半个文件Sender Name Resolution、message enrichment、`--download-resources`、Card Messages 限制、Flag 两层、Feed Shortcut 限制——本轮**一条都没用到**,却每题常驻。
- `## Shortcuts` 全表L91114逐条列 20+ shortcut含 flag/feed-group/feed-shortcut/reactions 等本轮完全不相关项。
- **可信度=常驻静态**tiktoken 可测、跨题稳定3,751×3。这是降 token 最稳的发力点,且 3 题全命中reach=1.0),降幅不被任何子集稀释。
- **axis=token**。文档位置:`skills/lark-im/SKILL.md``## Important Notes` 低命中小节 + `## Shortcuts` 全量表。
- **方向张力(必须标注)**:这是 round-1 已经动过一刀的同一文件(折叠了 API Resources/权限表)。再压 Important Notes/Shortcuts 是**同向继续**,但**剩余内容大多是 identity/约束类**——删错会碰坏 015/080 已走通的 bot 身份判断。candidate-writer 取舍时这是 effect 风险点,不是 RC-1 不成立。
### RC-2token次级抓手080 命中、按需读取)—— `messages-send.md` 单文件偏大且内部高度冗余
- **现象**080 读了 `messages-send.md`,实测 **5,365 tok**——本轮所有按需 reference 里最大的单块(占 080 visible 的 24.8%)。该 reference 实测被读且**确实用上了**080 据此发卡片成功),不是「读了没用」。
- **从文档看为何这么大**messages-send.md264 行)内部「怎么选 content flag」重复表述 4 处——`## Choose The Right Content Flag`(L2342)、`## What --markdown Really Does`(L4492)、`## Preserving Formatting`(L94112)、`## Common Mistakes`(L192201)语义大量重叠;`## Commands`(L114161) 15+ 例覆盖 image/file/video/audio/idempotency 等本轮用不到的形态。这是「单文件冗余 + 全形态罗列」,不是信息缺失。
- **可信度=按需读取**只在实读它的子集reach=0.333,仅 080里计入压缩降幅在该子集不被稀释——但**子集只有 1 题**,证据基数小,效果需评测确认(见数据缺口)。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md`
### RC-3token次级抓手014+080 命中、按需读取)—— `chat-create.md` 按需读取偏大
- **现象**014 与 080 都读了 `chat-create.md`,实测 3,0603,062 tokreach=0.667。080 据此建群成功用上了014 读后因 user 授权阻断没走到建群(读了但本题没用上)。
- **可信度=按需读取**reach=0.667,子集 2 题)。体积本身不离群,杠杆低于 RC-2列为更次级。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-chat-create.md`
### RC-4效果无文档根因 / 本轮不可修)—— 014 的 user 授权阻断 + 跨域 contact 依赖
- **现象**014 需先解析「傅一铭/傅二铭」open_id`contact +search-user`**lark-contact 域,不在 candidate_modules**bot 身份 exit2invalid_argument`--as user` token_missing → 发起 `auth login`+qrcode → 停在扫码。判分点证据全 ✗。
- **归因落点**:根因=沙箱不能交互扫码(环境)+ 跨域 contact 命令不可用(非 lark-im。**lark-im 文档侧无根因、无可修点**——这正是约束 3 的「无文档根因 / 本题不改」出口,不要为凑根因往 lark-im doc 上硬编。
- **axis=效果**,标注**无文档根因 / 本轮不改**。effect 维持 baseline 即可,不要试图改路由让 014「修绿」用户显式要本人身份解析联系人改 bot 是 reward-hack
## 命令失败热点(跨 case失败类型由我从 timeline 命令串读出,非判决数字)
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|---|---|---|---|---|
| `contact +search-user` | 4 | 1 (014) | bot exit2(invalid_argument) ×2user token_missing ×2 | **跨 lark-contact 域**,非 lark-im 内容 |
| `auth qrcode --output 绝对路径` | 1 | 1 (014) | unsafe output path改相对路径重试成功 | 路径约束在 lark-shared不可改 |
| `im +messages-search` | 2 | 1 (015) | exit2bot 身份 + `--as user` 均 exit2 | 见下「messages-search 难用」分析 |
| `im +chat-messages-list --page-all` | 1 | 1 (015) | exit2无过滤 page-all | 见下「015 token 黑洞」分析 |
- **解读**:本轮**没有一条 lark-im 命令因「参数名/类型写错」系统性失败**。080 三条命令 0 失败015 的失败集中在 `messages-search`(见下)。这意味着**没有 lark-im 侧的常规「报错/参数整形」工单**——与 RC-1/2/3 的 token 方向一致,本轮抓手是减体积不是补内容。
### 015 的 token 黑洞重要的新发现round-1 完全没诊断到)
- 015 真正的 token 大头**不是任何 lark-im doc**,而是 **block #19一次 `Read` 工具读入 22,556 tok占该题 visible 51.5%**。成因链:#17 `+messages-search` exit2 → 退而求其次 #18 `+chat-messages-list --page-all`(无时间过滤)→ 输出 43.5KB 被持久化到文件 → agent `Read` 整个文件 → 22.5k tok 灌进上下文。后面又靠本地 `grep`(#2733) 抠出「飞豆」两条。
- **从文档角度**`chat-messages-list.md` **本题 reach=0**(没读到),而它恰好写了 `--start/--end` 时间过滤、`--page-size`、「无 sender 排序」等能避免全量拉取的约束L2052。SKILL.md 表里对该 shortcut 只写「supports time range/sort/pagination」一句、未提示「大群全量拉取会爆上下文、应先 server-side 收窄」。**这是一个真实的「该读没读 → 全量灌入」放大器**(约束 5 状态①:调用前从没读该 reference
- **但这条对本轮目标是「方向张力」,不是干净的 token 抓手**:要避免全量灌入,文档侧只能**增加**收窄指引(前置或加 caution这与「降 token」的常驻成本目标**方向相反**(见硬性约束 7 的冲突记录)。且 22.5k 黑洞是**单次工具输出**(单次输出可信度、单题、强烈依赖该群消息量),不是稳定常驻热点。**结论:列为观察项交评测裁决,不要当成 RC-1 那种干净抓手去推「前置 chat-messages-list」——很可能只增 token 不省。**
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
> 对每条相关 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash本轮 3 题均未跑任何 `--help`)。
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错/卡 | 主导态 → 改动方向 |
|---|---|---|---|---|---|
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | — | 三题调用前都读了014 仍卡(环境,非内容);不可改 |
| `chat-create.md` | 0.667 | 0 | 0 | — | 080 调用前读→建群成功014 调用前读→授权阻断(非 reference 错)。**非触达问题** |
| `messages-send.md` | 0.333 | 0 | 0 | — | 080 调用前读→发卡片成功。**非触达问题** |
| `chat-messages-list.md` | 0.0 | 1 (015) | 0 | — | ① **015 调用前从没读**→直接 `--page-all` 全量拉取→token 黑洞。触达缺口,但补它=增 token与目标冲突见上 |
| `messages-search.md` | 0.0 | 1 (015) | 0 | — | ① 015 从没读 messages-search.md直接猜 `+messages-search` ×2 → exit2。该命令 user-onlySKILL 表 L101 已注明bot 身份必败 |
- **结论**:本轮 effect 失败的唯一真题014是**状态③语义但根因是环境**(内容已触达、卡在沙箱授权+跨域),**前置/补内容救不了**。015 的两处 ① 触达缺口chat-messages-list / messages-search 没读)确实存在,但**修它们的方向(增内容)与本轮 token 目标相反**,且 015 最终已 PASS靠 bot + 本地 grep 兜底)——所以这两处**不是必须修的 effect 缺口,只是 token 放大器**,且修了大概率反而增 token。
- **对 candidate-writer 的含义****本轮没有「该前置」的干净 case**。RC-1/2/3 都是「调用前已读、内容够用 → 减体积」的纯 token 减法,不涉及触达。不要被 015 的两处 ① 诱导去推前置——那会与目标背道而驰。
## 方向冲突记录(硬性约束 7
- **减体积RC-1/2/3与 objective.direction 同向)** vs **补收窄指引(修 015 chat-messages-list 全量灌入,与 objective 反向)**:前者降常驻/按需 token后者为省「单次工具输出」反而要**增**文档常驻 token。两者方向相反**不可合并**。本轮目标是降 token应取减体积一侧015 的全量灌入作为观察项记录、不作为本轮要补的内容根因。
## 差距台账复盘
-round 2`discard-ledger.json` 为空,无已跑未采纳候选)。
## 逐 case
### 1 (014) [workorder=PASS / 实质 FAIL] token=34555(reported)/visible 17,364 耗时=37s 命令失败率≈5/7 维度=效果(不可修)
- 判分点结果3 条全 ✗——建群/拉人/发消息全未发生,卡在 `contact +search-user` 解析 open_iduser 授权阻断。verdict=PASS 系聚合口径,按判分点证据当 FAIL 处理。
- 命令失败≈5/7。`contact +search-user` bot exit2 ×2、user token_missing ×2`auth qrcode` 绝对路径 unsafe ×1改相对路径成功。**全部非 lark-im 命令的内容错误**。
- 可发现性时序:调用前读了 SKILL.md(reach=1.0)+chat-create.md(3,062 tok);失败在更上游的跨域 contact + 授权。**非 lark-im 触达问题**。
- token 归因SKILL.md 正文 3,751常驻静态21.6%+ chat-create.md 3,062按需17.6%,本题没走到建群=读了没用上)+ 系统 Skill 列表注入 4,612固定开销不归因。lark-cli 命令累计含多次短失败回显,单条都短、非热点。
- 耗时归因:本题往返多(查联系人→切 contact→失败→auth status→授权→qrcode 重试)。多为授权链路 + 跨域固有串行 + 反应式重试duration 弱信号,需多轮复现)。
- 文档根因:效果=沙箱 user 授权 + 跨域 contact环境**无 lark-im 文档根因,本轮不改**token=SKILL.md 常驻RC-1+ chat-create.md 按需RC-3
### 2 (015) [PASS·真] token=54568(reported)/visible 43,760 耗时=2m5s 命令失败率≈3/9 维度=token
- 判分点结果3/3 ✓——定位群、转发「飞豆」消息、@傅六铭知会全部成功(两次 `messages-send``ok:true`)。**全程 bot 身份,无授权阻断**。
- 命令失败≈3/9。`+messages-search` bot exit2、`+messages-search --as user` exit2、`+chat-messages-list --page-all` exit2无过滤agent 退到 `+chat-messages-list`(无 page-all) + 本地 grep 兜底成功。
- 可发现性时序:① `messages-search.md` / `chat-messages-list.md` **调用前从没读**reach=0直接猜命令。messages-search 是 user-onlySKILL 表 L101 已注明、bot 身份必败——agent 没看清就猜。
- token 归因:**本题 token 大头不是 lark-im doc**,是 block #19 一次 `Read` 持久化文件 = **22,556 tok51.5%,其他工具调用/返回)**,成因=`--page-all` 无过滤全量拉取→43.5KB→Read 灌入单次输出可信度强依赖该群消息量。SKILL.md 正文 3,749常驻。lark-shared 3,749跨 skill不归因 lark-im
- 耗时归因:本题最长(2m5s),主因是 messages-search 连环失败→改用 page-all→大输出→多次本地 grep 抠数据的多轮往返duration 弱信号;工具调用 16 raw32明显高于 080作旁证
- 文档根因token 黑洞的放大器=`chat-messages-list.md` 没被读到 + SKILL.md 表未提示大群应 server-side 收窄——但**补这条与降 token 目标相反**(方向张力,见上),列为观察项;本题已 PASS。常规 token 抓手仍是 RC-1SKILL.md 减体积)。
### 3 (080) [PASS·真] token=38009(reported)/visible 21,599 耗时=47s 命令失败率=0/3 维度=token
- 判分点结果3/3 ✓——`auth status` 见 bot ready→主动选 bot→建群`ok:true`→发 interactive 卡片`ok:true`。**任务完整完成,零命令失败**。
- 命令失败0/3。三条 lark-cliauth status / chat-create / messages-send全成功。
- 可发现性时序:调用前读 SKILL.md + chat-create.md(3,060) + messages-send.md(5,365),全部状态③(调用前已读且用上)。**无触达问题**。
- token 归因:**本题是纯 token 抓手题**——读取 Skill 占 56.4%messages-send.md 5,365按需最大单块RC-2+ SKILL.md 3,751常驻RC-1+ chat-create.md 3,060按需RC-3。三块 reference/SKILL 都实读且 RC-2 的 messages-send.md 确实用上了。系统 Skill 列表注入 4,612固定开销不归因
- 耗时归因47s全部为正常建群+发卡片串行,无重试、无写后回查(无离群)。
- 文档根因无效果根因已绿token=RC-2(messages-send.md 内部冗余) + RC-1(SKILL.md 常驻) + RC-3(chat-create.md)。**本题 token 杠杆最高且无 effect 风险**(命令全成功,减 reference 体积不碰已走通链路)。
## 给 candidate-writer 的收口(不含具体改法)
- **唯一在 T1 内可合法发力的轴是 token**,且本轮是**纯减体积**场景(无触达缺口要补、无参数错误要改):
- **RC-1**SKILL.md `## Important Notes` 低命中小节 + `## Shortcuts` 全表3 题全命中、常驻静态、最稳,但剩余多为 identity/约束类,删错会碰坏 015/080 已走通的 bot 身份判断——**effect 风险点**。
- **RC-2**messages-send.md 内部 4 处「选 content flag」语义重叠 + 全形态 Commands单文件最大块、内部冗余明确但子集只有 080 一题reach=0.333),证据基数小、效果需评测确认。
- **RC-3**chat-create.md 按需偏大):杠杆最低,列为更次级。
- **effect 不可在本轮 T1 内合法抬升**014 是环境(沙箱不能扫码)+ 跨域 contact无 lark-im 文档根因。015/080 已真 PASS。候选必须**保住 015/080 走通 bot 身份的 identity/参数说明**,降 token 时别误伤。
- **不要推前置**:本轮没有「该前置」的干净 case。015 的两处触达缺口chat-messages-list/messages-search 没读)虽真实存在,但修它们=增内容,与降 token 目标**方向冲突**,且 015 已 PASS——属观察项非本轮要补的根因。
- **缺失信息doc_fix_hint 语气)**SKILL.md 的 Important Notes/Shortcuts 全量罗列、本轮低命中却每题常驻messages-send.md 同一选型规则在 4 处重复表述、Commands 罗列全部媒体形态——这类「全量/重复、低命中」内容是 token 的主要去处,且是减法(删冗余)而非加法。
- **数据缺口**(a) workorder 三题 verdict 全 PASS但 014 判分点证据全 ✗——归因按判分点当 FAIL 处理effect 实际是 2 真 PASS + 1 实质 FAIL。(b) RC-2/RC-3 子集小messages-send.md 仅 080、chat-create.md 仅 014+080单轮证据基数小token 降幅需评测在子集上确认。(c) 015 的 22.5k 黑洞是单次工具输出,强依赖该群消息量,非稳定常驻热点,单题不可外推。(d) duration 三题波动大37s/2m5s/47s015 长尾主因是 messages-search 连环失败+大输出多轮抠数据,但单轮不足以定论,需多轮复现;工具调用数(8/16/6 model calls)可作比 wall-clock 稳的旁证。(e) 工具调用次数 session-analyze(model calls 8/16/6) 与 workorder 趋势表(R1 均值 26.3) 口径不一致,趋势表疑似含 raw 计数,旁证以 timeline 实际往返为准。

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,220 @@
{
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
}

View File

@@ -0,0 +1,15 @@
{
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"reason": "纯结构性去重16407→6399 字节(-61%)与策略一致;逐项核对每条承重指令(互斥规则、video-cover 必配、cwd-relative/绝对路径拒绝、markdown→post 边界、三套 <at> 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射)均原样保留在新文档内联,删的全是重复/过度罗列(4× 选型规则、镜像 --help 的 Parameters 表、Common Mistakes、Notes、冗余 Commands)。无硬编码评测答案、未针对 080 卡片流窄化、未碰 SKILL.md 身份路由、单文件单根因。",
"dimensions": {
"reward_hack": {"pass": true, "evidence": "无硬编码 eval ID/答案(仅 oc_xxx/ou_xxx 等通用占位符,与原文一致)card/interactive+bot 身份路径保留为通用指引,未按 080 卡片流做特判或窄化"},
"semantic_regress": {"pass": true, "evidence": "逐条核对:互斥/video-cover/cwd-relative+绝对路径拒绝/markdown→post/三套 <at>/content 全 msg_type 样例/Safety/identity+scope 全部内联保留;仅删除的是真重复(dry-run 占位符细节、JSON wrap 示意、img_/file_ 自动识别说明),非承重 guardrail且运行时可观测"},
"token_shift": {"pass": true, "evidence": "真减 10008 字节常驻;--help 指针是 additive 补充(指向真实存在且 --help 已含互斥/video-cover/路径规则),承重 gotcha 全留内联080 不需额外调 --help 即可恢复,无运行时增读拉力。注:work-order 提的 schema im.messages.create 方法不存在,但文档本身不指向 schema不构成运行时陷阱"},
"contract_break": {"pass": true, "evidence": "T1 文档不涉对外契约prerequisite 链接目标存在、章节结构完整、无其他文件深链到被删 anchor(Media Input Rules/Common Mistakes 命中在 messages-reply.md 而非本文件)"},
"devguide": {"pass": true, "evidence": "符合 reference 收敛到 gotcha-only、不镜像 --help 的优化方向;同一事实只写一处,删的两类(语义回退/承重删除)均未触发——优化红线两维过关"},
"single_root_cause":{"pass": true, "evidence": "commit 仅 1 文件 51 insert/208 delete全部服务 RC-2(单文件重复表述去重)一个根因;未捆 RC-1(SKILL.md)/RC-3(chat-create),未把无关删除以 token 对冲缝入"}
}
}

View File

@@ -0,0 +1,380 @@
{
"round": 2,
"status": "admitted",
"parent_id": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"parent_worktree": "/Users/bytedance/Projects/cli",
"child_worktree": "/Users/bytedance/Projects/cli",
"base_commit": "51f2a70e6dffeea65d928badb6207408490dc215",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"candidate_modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"module_reach": {
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
},
"expected_reach": {},
"minibatch": [
"1",
"2",
"3"
],
"pareto_cases": [
"1",
"2",
"3"
],
"artifacts": {
"workorder": "workorder.md",
"diagnosis": "diagnosis.md",
"attribution": "attribution.json",
"strategy": "strategy.md",
"review": "review.json",
"trend": "trend.json"
},
"code_tip": "82a099feafb45d101116f10230ce7c2f92fbcfe5",
"signature": "557349b40feb359bb791749a37571d59edb7e72e",
"tier": "T1",
"intent": "consolidate 4x repeated content-flag rule + compress media enumeration & --help-mirror sections in messages-send.md (token, no capability removed)",
"target_axis": "token",
"changed_files": [
"skills/lark-im/references/lark-im-messages-send.md"
],
"decision_basis": {
"type": "module",
"module": "skills/lark-im/references/lark-im-messages-send.md"
},
"decision_cases": [
"3"
],
"review": {
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"reason": "纯结构性去重16407→6399 字节(-61%)与策略一致;逐项核对每条承重指令(互斥规则、video-cover 必配、cwd-relative/绝对路径拒绝、markdown→post 边界、三套 <at> 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射)均原样保留在新文档内联,删的全是重复/过度罗列(4× 选型规则、镜像 --help 的 Parameters 表、Common Mistakes、Notes、冗余 Commands)。无硬编码评测答案、未针对 080 卡片流窄化、未碰 SKILL.md 身份路由、单文件单根因。",
"dimensions": {
"reward_hack": {
"pass": true,
"evidence": "无硬编码 eval ID/答案(仅 oc_xxx/ou_xxx 等通用占位符,与原文一致)card/interactive+bot 身份路径保留为通用指引,未按 080 卡片流做特判或窄化"
},
"semantic_regress": {
"pass": true,
"evidence": "逐条核对:互斥/video-cover/cwd-relative+绝对路径拒绝/markdown→post/三套 <at>/content 全 msg_type 样例/Safety/identity+scope 全部内联保留;仅删除的是真重复(dry-run 占位符细节、JSON wrap 示意、img_/file_ 自动识别说明),非承重 guardrail且运行时可观测"
},
"token_shift": {
"pass": true,
"evidence": "真减 10008 字节常驻;--help 指针是 additive 补充(指向真实存在且 --help 已含互斥/video-cover/路径规则),承重 gotcha 全留内联080 不需额外调 --help 即可恢复,无运行时增读拉力。注:work-order 提的 schema im.messages.create 方法不存在,但文档本身不指向 schema不构成运行时陷阱"
},
"contract_break": {
"pass": true,
"evidence": "T1 文档不涉对外契约prerequisite 链接目标存在、章节结构完整、无其他文件深链到被删 anchor(Media Input Rules/Common Mistakes 命中在 messages-reply.md 而非本文件)"
},
"devguide": {
"pass": true,
"evidence": "符合 reference 收敛到 gotcha-only、不镜像 --help 的优化方向;同一事实只写一处,删的两类(语义回退/承重删除)均未触发——优化红线两维过关"
},
"single_root_cause": {
"pass": true,
"evidence": "commit 仅 1 文件 51 insert/208 delete全部服务 RC-2(单文件重复表述去重)一个根因;未捆 RC-1(SKILL.md)/RC-3(chat-create),未把无关删除以 token 对冲缝入"
}
}
},
"child_k": 5,
"eval_trace": null,
"retro": {
"cause": "已入池",
"noise_borderline": false,
"summary": "越带入池,无需复盘补发"
},
"retro_sessions": [
{
"case": "3",
"session": null,
"axis": "token",
"expect": "降",
"parent": 37942,
"child": 35478,
"gain": "收益现",
"pass_delta": null
}
],
"verdict": "admitted",
"ci": null,
"new_candidate": "557349b40feb359bb791749a37571d59edb7e72e",
"decision": {
"parent_success": 1.0,
"child_success": 1.0,
"parent_score": 0.6,
"child_score": 1.0,
"score_saved": 0.4,
"score_threshold": 0.09532271373123208,
"parent_token": 37942.0,
"child_token": 35478.0,
"saved": 2464.0,
"threshold": 4532.708313776408,
"parent_duration": 45769.0,
"child_duration": 46540.0,
"dur_saved": -771.0,
"dur_threshold": 4899.200953624988,
"dur_margin": 1.0,
"missing_duration": [],
"k_child": 5,
"k_parent": 5,
"decision_n": 1,
"missing_context": [],
"missing_score": [],
"parent_token_acc": 251669.0,
"child_token_acc": 221685.0,
"phi0_score": 0.5333333333333333,
"eff_margin": 1.0,
"parent_token_full": 37942.0,
"child_token_full": 35478.0,
"saved_full": 2464.0,
"observe_n": 1,
"target_axis": "token",
"admitted": true,
"reason": "score_gain"
},
"patch": "verify_results/round-002-lark-im-references-lark-im-messages-send.patch"
}

View File

@@ -0,0 +1,48 @@
# Round 2 候选策略(模块=references/lark-im-messages-send.md, tier=T1, 主指标=token
## 根因与选择
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|---|---|---|---|---|---|---|
| RC-2: messages-send.md 单文件最大、内部「选 content flag」规则重复 4 处 + 全媒体形态罗列 + Parameters/Notes 镜像 --help | 评测归因①080 实读实用规范经验②annotation R1×140/R2×109仅 1 段 R3 | references/lark-im-messages-send.md (0.333) | R1/R2 主导,唯一 R3=Safety Constraints(L922) | 密 / overfit 低 | P1 | ✅ |
| RC-1: SKILL.md `## Important Notes` 低命中 + `## Shortcuts` 全表常驻 | 评测归因①reach=1.03 题全命中) | SKILL.md (1.0) | R2/R3 混合identity/约束密集) | 密 / 中 | P0(命中) 但 effect 高风险 | |
| RC-3: chat-create.md 按需偏大 | 评测归因① | references/lark-im-chat-create.md (0.667) | — | 密 | P1 | |
- **选中理由**RC-2 是诊断点名「最干净的 token 杠杆」——单文件最大块(实测 ~5,365 tok占 080 visible 24.8%),且 080 调用前已读、确实据它发卡片成功reach=0.333、actual=1非「读了没用」。annotation 证实它 R1/R2 主导140 R1 + 109 R2 行,可重构/可压缩),唯一 R3 段是 Safety Constraints(L922),我**原样保留语义**。coverage=「密」、overfit「低」→ 本轮 eval 能在 080 上裁真伪。这是纯减体积、零能力删除、不碰 SKILL.md 路由的改动。
- **为什么不选 RC-1**reach=1.0、命中率最高,但 diagnosis 明确标它为 **effect 风险点**——剩余内容多为 identity/约束类,正是驱动 015/080 走通 bot 身份判断的承重内容objective 的**硬门槛是「保住成功率」**,动 SKILL.md 最可能误伤这条已绿链路。本轮放弃,避免拿成功率换 token。
- **为什么不选 RC-3**diagnosis 判其杠杆最低(体积不离群),列为更次级;同一根因一轮只动一个,留待后续轮次。
- **选模块理由**messages-send.md reach=0.333>0满足 reach 锁),承载选中的 RC-2是非域 reference、改它不触碰 SKILL.md 的身份路由面。多文件无——本轮只动这一个文件。
- **规范经验源补注**:对照 content-taxonomy——「单命令用法/长示例/与 --help 重复」类默认 R0/R1「一般行为规则/CLI 机制约定」默认 R2本文件的重复选型规则、全形态 Commands、Parameters/Notes 镜像即此类,处理方向为「留命中率最高一处,其余删或指针」「高频留 23 例,长的下沉」。当轮可被 080 裁真伪coverage 密/overfit 低)。
## 改了什么(逐处)
- **L2343 `## Choose The Right Content Flag` + `### --text vs --markdown`**:两段语义重叠的选型说明 → 合并为单张 4 行选型表markdown/text/content/media并把互斥规则并入表后一句。删掉 `### --text vs --markdown` 整段(与表重复)。
- **L4482 `## What --markdown Really Does` + `### Markdown Boundaries` + `### Image Constraint`**:三段约 39 行 → 压成 `## --markdown Gotchas` 三条要点(强制 post/无 title、标题改写规则、图片预上传 vs 远程 URL vs 本地路径不支持)。删掉 JSON wrap 示意、逐条 boundary 罗列等可由行为观察得到的展开。
- **L8393 图片预上传双命令示例**:并入 `## Commands` 的一条 markdown+image 示例(保留 `im images create` → 引用 img_xxx 的关键两步)。
- **L114161 `## Commands`15+ 例覆盖全媒体形态)+ `## Media Input Rules`**压成代表性示例markdown / text / DM / post-title / markdown+image / 4 个媒体一组 / idempotency+dry-run媒体路径规则收成 `--help` 指针后的 3 条 load-bearing gotchacwd-relative/绝对路径拒绝、video-cover 必配、msg-type 推断冲突)。
- **L169191 `## Parameters` 表**:删除镜像 `--help` 的逐参数描述改为「Run `lark-cli im +messages-send --help`」指针 + 仅保留 --help 不显然的三条硬规则(已并入 Commands 末尾)。
- **L192202 `## Common Mistakes`**:整段删除——逐条都是选型表/markdown gotcha 的反向重述(第 4 次重复选型规则),删后选型信息仍在表里。
- **L203216 `## content Format Reference`**:保留(构造 `--content` 的 gotcha把 image/file/audio 三行合并为一行省重复。
- **L227248 `## @Mention Format`**:保留全部三种 msg_type 的 `<at>` 语法text/post/interactive 各异、AI 猜不到),压紧为两条要点、去掉小标题与重复散文。
- **L249264 `## Notes`**:整段删除——逐条(互斥/media 上传/scope/markdown 强制 post/video-cover/msg-type 冲突)均已在 Safety Constraints、选型表、--markdown Gotchas、Commands 指针处各保留一处单一事实源。
## 为什么这么改(机制)
- **消除根因的因果链**:该 reference 的体积来自「同一份选型规则在 4 个 section 重复 + 全媒体形态逐条罗列 + Parameters/Notes 镜像 --help」。token 不是被任务必需信息占用,而是被**重复表述**占用。按「同一份事实只写一次」(锚点 1合并到单一事实源后每条 load-bearing 信息仍恰好出现一次080 这类「读该 reference→发消息」的题读入 token 直接下降而行为不变。
- **不删能力**:每个 flagtext/markdown/content/image/file/video/audio/idempotency/dry-run/msg-type/video-cover/as、每条硬约束互斥、video-cover 必配、cwd-relative 路径、绝对路径拒绝、markdown 强制 post/无 title、msg-type 冲突校验)、三套 `<at>` 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射——全部保留,只是从「重复 N 次/逐条罗列」变成「一处/代表性示例 + --help 指针」。
- **规范经验源**:依 optimization-playbook「reference 收敛到 gotcha-only不做 --help 镜像」——Parameters 全表/全形态 Commands 属 USAGE下沉到 `--help` 指针;保留的是 --help 表达不了的跨 flag 互斥、媒体路径安全、markdown→post 边界、@mention 按类型差异等 gotcha。annotation 标这些段为 R1可重构/下沉),符合处理方向;唯一 R3Safety原样保留。
## 预期效果
- **成功率**不退化。080唯一读该文件的题的发卡片链路依赖的是 `--content`/`interactive`、identity=bot、chat-id——全部保留选型表、content Format Reference、Safety、scope 都在。015/080 走通 bot 身份的判断由 SKILL.md + identity 段承载,本轮**没碰 SKILL.md**零误伤面。014 与本文件无关reach 不含 014
- **context分两层**
- (1) **静态字数差**16,407 → 6,399 chars-61.0%tiktoken cl100k 3,869 → 1,799 tok-53.5%diagnosis 报 ~5,365 tok 系另一 tokenizer/含注入开销;此处用 cl100k 自测,方向与幅度一致。)
- (2) **运行时 context 方向**:仅在**实读该 reference 的子集**生效——本轮即 080 一题,运行时读入下降约 50%+(该块占 080 visible 24.8%,预计 080 visible 降约 1213%。其余两题014/015不读该文件运行时 token **不变**(既不增也不减)。这是按需 reference不是常驻面不会影响未读它的题。
- **覆盖敞口**RC-2 子集仅 080 一题reach=0.333证据基数小。coverage 判该文件「密/overfit 低」,本轮 eval 可在 080 上裁真伪,但单题不可外推到「所有发消息任务」。建议后续补「读 messages-send.md 后用 --markdown / 媒体 / @mention」的 case 加厚子集。预期收益落在 **token 轴**080 visible 下降effect 轴维持不退化。
## 刻意没做什么(反 reward-hack / 反过拟合)
- 没硬编码任何评测题答案没删任何能力、flag、guardrail、身份/scope 说明;没碰 lark-im 以外文件也没把无关根因捆进本轮commit 仅 1 个文件)。
- **没碰 SKILL.mdRC-1**:尽管 reach=1.0 杠杆最大,但其剩余内容是驱动 015/080 bot 身份判断的承重 identity/约束diagnosis 标为 effect 风险点;在「保住成功率」硬门槛下不拿成功率换 token。
- **没补收窄/分页指引**015 的 22.5k chat-messages-list 黑洞):那是「增内容」,与降 token 目标方向相反diagnosis 已列为观察项、本轮不做。
- 本改动**不是按评测错误反推**的参数/路由拟合——是基于 annotation + content-taxonomy 的结构性去重,删的是重复表述而非按 080 的具体内容裁剪;真实价值在「任何读该 reference 的发消息任务都少读重复 token」080 只是当轮可验证的子集。
- 未发现需要 breakingT3才能根治的点本轮纯 T1 文档去重即可。
## 签名
- signature: 557349b40feb359bb791749a37571d59edb7e72e (commit 82a099fe 的 diff hash) tier: T1

View File

@@ -0,0 +1,11 @@
[
{
"round": 1,
"n": 3,
"pass_n": 0,
"cmd_fail_rate": 0.6,
"tool_calls": 26.333333333333332,
"duration_ms": 50189.0,
"token": 31997.0
}
]

View File

@@ -0,0 +1,43 @@
# Round 2 归因派工单parent=a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e模块未定由 candidate-writer 据诊断点名)
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer+ 逐题结构化 `attribution.json`(给 dashboard。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置正该选来修——不是白烧reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3']
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3']
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.333;判决集(实测∪预期): ['3']
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
## 逐轮诊断信号趋势(纯诊断,不进判决)
| 轮 | 题数 | PASS | 命令失败率 | 工具调用 | 耗时(ms) | token |
|---|---|---|---|---|---|---|
| R1 | 3 | 0 | 0.60 | 26 | 50189 | 31997 |
> 跨题均值,按轮排。**命令失败率、工具调用数是横切诊断信号,不是准入轴**(准入只走 效果/token/耗时)——用来判「上一轮那刀有没有把失败/轮次压下去」。工具调用数比 wall-clock 稳,可给噪声大的耗时轴当旁证。
### 1 [PASS] ctx=34270 (acc=274608) 43995ms tools=31
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✗ 使用当前用户身份创建名为「IM合作群」的群聊
证据: transcript 在展示授权二维码后结束,无任何 `lark-cli im +chat-create` 调用。执行停在 '授权完成后请告诉我,我会继续帮你创建群聊并发送消息',群聊未创建。
✗ 将傅一铭和傅二铭加入该群
证据: transcript 显示尝试搜索用户时遇到 `need_user_authorization` 错误,授权流程启动后中断。未获取到任何用户的 open_id无后续添加操作。
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
证据: 群聊未创建,无 chat_id 可返回。transcript 无任何 `lark-cli im messages-send` 调用。
### 2 [PASS] ctx=47116 (acc=612048) 114310ms tools=49
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 成功定位名为「fusanming_at_openclaw群」的群并获取最近包含「飞豆」关键字的消息
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功
### 3 [PASS] ctx=37942 (acc=251669) 45769ms tools=23
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
✓ 将该卡片发送到新建群中,预期返回 message_id

View File

@@ -0,0 +1,59 @@
[
{
"case_id": "1",
"verdict": "PASS",
"verdict_note": "workorder=PASS聚合口径判分点证据 3/3 ✗,按判分点当实质 FAIL 处理",
"token": 34555,
"duration_ms": 37000,
"tool_calls": 31,
"cmd_attempts": 7,
"cmd_failures": 5,
"cmd_fail_rate": 0.71,
"discoverability_state": "无(失败命令全是跨域 contact + auth非 lark-imchat-create.md 调用前已读但未走到使用)",
"axis": "效果",
"root_cause": "沙箱 user 授权不可完成 + 跨域 lark-contact 命令依赖;无 lark-im 文档根因,本轮不改",
"token_hotspot": "运行时冗余清单常驻SKILL.md 3,456+ 按需 chat-create.md 3,062读了没用上lark-shared 3,751 与系统 Skill 列表注入 4,612 均不归因",
"token_reliability": "常驻静态SKILL.md/ 按需读取chat-create.md本题读了没用上",
"duration_hotspot": "多轮交互(查联系人→切 contact→失败→授权→qrcode 重试)+ 纯外部API延迟(部分不可归因)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "效果侧无 lark-im 文档缺信息(环境+跨域token 侧 chat-create.md 把同组 flag 在 Commands/Usage Scenarios 重复演示、Common Errors 复述 validation 字符串,属可删冗余"
},
{
"case_id": "2",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,全程 bot 身份无授权阻断",
"token": 54568,
"duration_ms": 125000,
"tool_calls": 49,
"cmd_attempts": 11,
"cmd_failures": 3,
"cmd_fail_rate": 0.27,
"discoverability_state": "① 从没读messages-search.md / chat-messages-list.md reach=0直接猜命令本题未读任何 lark-im reference",
"axis": "token",
"root_cause": "无过滤 +chat-messages-list --page-all 全量拉取 → 43.5KB 输出被 Read 整文件灌入 22,556 toktoken 大头非 lark-im doc。修它需补收窄/前置内容,与降 token 目标方向冲突,列观察项",
"token_hotspot": "工具返回原样输出block #19 单次 Read 22,556 tok / 51.5%,归「其他工具调用/返回」)",
"token_reliability": "单次输出(强依赖该群消息量,单题不可外推,非稳定常驻热点)",
"duration_hotspot": "多轮交互 + 重试messages-search 连环 exit2 → page-all → 大输出 → 多次本地 grep 抠数据)",
"duration_reliability": "耗时波动大单次运行不算数需多题或多次复现model calls 16 作旁证,明显高于 080",
"doc_fix_hint": "本题无 T1 可发力的 token 抓手(大头是单次工具输出,非 lark-im doc 常驻);缺的是大群消息查询的 server-side 收窄指引,但补它=增内容、与降 token 反向,不作本轮根因"
},
{
"case_id": "3",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,主动选 bot 身份建群+发卡片均 ok:true零命令失败",
"token": 38009,
"duration_ms": 47000,
"tool_calls": 22,
"cmd_attempts": 3,
"cmd_failures": 0,
"cmd_fail_rate": 0.0,
"discoverability_state": "无无失败命令SKILL.md + chat-create.md + messages-send.md 全部状态③:调用前已读且用上)",
"axis": "token",
"root_cause": "读取 Skill 占 56.4%;本轮唯一干净 token 抓手 = chat-create.md 内部冗余(示例罗列 + 场景重复 + --help 镜像),从未被优化过",
"token_hotspot": "运行时冗余清单常驻 + 按需 referencechat-create.md 当前 2,336 raw可压 Commands/Usage Scenarios 重叠 + Common Errors validation 镜像trace 里 messages-send.md 5,365 是旧版round-2 已压到 2,006本轮不再可压",
"token_reliability": "按需读取chat-create.md reach=0.667,本题是其压缩收益唯一稳态兑现题)",
"duration_hotspot": "无离群(建群+发卡片正常串行,无重试、无写后回查)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "chat-create.md 把同组 flag 在 Commands(12 例)+Usage Scenarios(3 场景)重复演示、Common Errors 多行复述 --help/报错本身就会吐的 validation 字符串属可删冗余232043 两步流 / --chat-mode topic 区分 / --owner 默认为载重红线,压缩中不可误删"
}
]

View File

@@ -0,0 +1,27 @@
{
"1": [
"auth login",
"auth qrcode",
"contact +search-user"
],
"3": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-messages-list",
"im +chat-search",
"im +messages-mget",
"im +messages-search",
"im +messages-send",
"im messages forward",
"schema im.messages.forward",
"schema im.messages.search"
]
}

View File

@@ -0,0 +1,20 @@
{
"1": {
"score": 1.0,
"passed": true,
"context_window": 33840,
"token_usage": 237434,
"duration_ms": 44127,
"tool_call_count": 25,
"feedback": "执行者成功完成了所有期望:首先搜索联系人获取 open_id首次搜索用单字失败后改为双字搜索成功然后使用 --as user 创建群组并添加成员,最后发送消息并返回 message_id。整个流程正确使用了等效的 `--as user` 身份,符合用户「使用我的身份」的要求。验证结果确认所有操作均已生效。"
},
"3": {
"score": 1.0,
"passed": true,
"context_window": 35942,
"token_usage": 234388,
"duration_ms": 43185,
"tool_call_count": 22,
"feedback": "执行者正确理解用户意图使用用户身份创建群并发送卡片消息。创建群组一次成功发送卡片经历了4次格式试错最初使用顶层 elements 和 tag:markdown后通过查阅官方文档找到正确格式body.elements + div + lark_md最终成功发送并返回 message_id。试错后自行纠正符合评判原则不构成判罚依据。\n- {'reason': '建议在 lark-im-messages-send.md 中增加飞书 interactive card 的标准格式示例,特别是 2.0 schema 下的 body.elements 中使用 div + lark_md 的正确写法,减少 AI 试错成本'}\n- {'reason': '建议 CLI 在遇到 230099 卡片格式错误时,尝试解析并返回更具体的字段级错误提示(如提示 \"elements 应在 body 内\" 或 \"tag:markdown 不被支持\"),帮助 AI 更快定位问题'}"
}
}

View File

@@ -0,0 +1,119 @@
# Round 3 归因parent=557349b…round-2 已采纳候选);候选模块见 candidate_modules由 candidate-writer 据诊断+reach 点名)
> 目标objective.json**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化token 与 duration 是并列成本杆。tier=T1仅可改 `skills/lark-im/**`。target_axis=token。
> 判分点只当「什么算挂」的锚,不抄 grader 药方。
## ⚠️ trace 与当前文件的版本错位(先看,决定本轮抓手是否还在)
**本轮派工单 trace = round-1 的全 3 题 child-runs**round-2 只评了 080故用 round-1 作最近的全覆盖代理)。这些 trace 里的 reference 体积是 **round-1/round-2 改动之前** 的旧版。我用 session-analyze 所用的同一 ai-tokenizer 实测了**当前工作树**文件,确认两者错位如下:
| 文件 | trace 内体积旧版Read 计) | 当前实测raw / Read 计) | 已被哪轮收割 |
|---|---|---|---|
| `SKILL.md`Skill 注入正文) | 3,4553,456 tok | 3,525 raw | round-1API Resources/权限表→schema 指针) |
| `references/lark-im-messages-send.md` | **5,365 tok** | **2,006 raw / 2,194 Read** | **round-25,365→2,006已收割** |
| `references/lark-im-chat-create.md` | 3,0603,062 tok | **2,336 raw / 2,645 Read** | **未动过2023 至今原样),唯一未收割** |
**含义**round-2 诊断里的 **RC-2messages-send.md 内部冗余)已经在 round-2 被采纳并收割**5,365→2,006它不再是本轮抓手——不要据 trace 里的 5,365 重复提一遍。本轮 trace 里那块 5,365 是历史值,当前已不存在。**reach>0 集合里唯一还没被压过的干净文件就是 `chat-create.md`**round-2 的 RC-3
## 跨 case 共同根因(优先看;按对 TOKEN 目标的杠杆排序)
### RC-1token本轮头号且基本是唯一的干净抓手reach=0.667014+080—— `chat-create.md` 内部存在「示例罗列 + 场景重复 + --help 镜像」三类可压缩冗余,且从未被优化过
- **现象**`chat-create.md` 当前 2,336 raw tokRead 计 ~2,645是 reach>0 集合里**唯一未被任何轮收割**的 reference。section 级实测分布raw tok
| section | tok | 性质 |
|---|---|---|
| header(1-11) | 198 | 载重scope/映射),保留 |
| **Commands(12-50) 12 个 bash 示例** | **425** | **过度罗列**:多条仅差一个 flag`--owner` / `--users` / `--bots` / `--as bot` / `--as user` / `--dry-run` 各一例),信息已在 Parameters 表里 |
| Parameters 表(52-69) | 500 | 多数载重;`--chat-mode` 的 L68 长注解与表内 L62 行语义重复 |
| AI Usage Guidance(70-108) | 442 | **载重**232043 两步流是 080/014 路由依据),但表述偏长 |
| Output Fields(109-119) | 126 | 载重 |
| **Usage Scenarios(120-143) 3 个场景** | **198** | **重复**Scenario 1/2 重复 Commands 已展示的 `--owner`/`--users`/`--bots` 组合Scenario 3 重复 messages-send 的串联用法 |
| **Common Errors(144-158) 9 行** | **395** | **部分 --help 镜像**:多行直接复述确定性 validation 字符串(`--name exceeds 60``--users exceeds 50``invalid user id` 等),这些 `--help` / 报错本身就会原样吐出 |
| References(159-163) | 44 | 载重 |
- **这正是 round-2 已经在 messages-send.md 上验证过、且被采纳的同一套压缩模式**round-2 把 messages-send.md 的「4 处重复选型规则 + 全媒体形态 Commands + --help 镜像」压成「保留载重规则 + 一句 `--help` 指针」5,365→2,006被采纳。chat-create.md 的 Commands(425)↔Usage Scenarios(198) 重叠、Common Errors(395) 的 validation 镜像,是同型冗余。
- **可压缩量级(粗估,非药方)**:可压缩质量集中在 Commands+Usage Scenarios 的重叠(合计 ~623 tok去重后可省一大半+ Common Errors 的 --help 镜像行。**保守估计可从 2,336 压到 ~1,5001,700 raw tok省约 600800 tok约 30%**,与 messages-send.md 的压缩比同量级。具体改法与确切降幅由 candidate-writer 决定、评测裁决。
- **载重红线candidate-writer 取舍时的 effect 风险点,不是 RC-1 不成立)**AI Usage Guidance 的 **232043 两步流 + `succeed_type=1`**`--chat-mode topic` vs 普通群+话题消息模式的区分、`--owner` 默认行为,是 014/080 走通 bot 身份建群的语义依据,**不能在压缩中误删**。这条 reference 被 080 实读且 080 据它建群成功(`ok:true`),所以 effect 风险真实存在——压的是示例/场景/报错镜像的体积,不是语义规则。
- **axis=token**。可信度=**按需读取**reach=0.667,子集=014+0802 题)。压它的降幅只在这 2 题子集里计入,不被 015没读它稀释但子集仅 2 题、且 014 是「读了没用上」(授权阻断没走到建群),实际吃到压缩收益的稳态题只有 080 一题——**证据基数小,降幅需评测在子集上确认**(见数据缺口)。
### RC-2token已收割本轮不再是抓手—— messages-send.md 的内部冗余 round-2 已压掉
- round-2 RC-2 已被采纳messages-send.md 5,365→2,006 raw。**本轮不要据 trace 里的 5,365 重复提**。当前 messages-send.md 已是「载重规则 + `--help` 指针」形态,无明显二次压缩空间(剩余多为 content-flag 选型、@mention、media 约束等载重内容。reach=0.333(仅 080
### RC-3token无 T1 干净抓手)—— SKILL.md 常驻正文 round-1 已压过,剩余多为载重 identity/路由
- SKILL.md 经 Skill 工具每题必加载reach=1.0),当前 3,525 raw tokround-1 已把 API Resources/权限表折叠成 schema 指针)。剩余 `## Important Notes`(L3685) 各小节Sender Name Resolution / message enrichment / `--download-resources` / Card / Flag / Feed Shortcut`## Shortcuts` 全表(L87115) 虽本轮 3 题低命中,但它们是**全域 identity/路由/约束**——这是 round-1 已经动过一刀的同一文件,**再压属同向继续、但删错会碰坏 015/080 已走通的 bot 身份与命令路由判断**effect 风险高于 RC-1。**列为更次级、风险更高的抓手**,不作为本轮首选;若要动须只删本轮已确证低命中且非路由的纯枚举行,谨慎程度高于 chat-create。
## 命令失败热点(跨 case失败类型由我从 timeline 命令串读出,非判决数字)
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|---|---|---|---|---|
| `contact +search-user` | 4 | 1 (014) | bot exit2(invalid_argument) ×2`--as user` token_missing ×2 | **跨 lark-contact 域**,非 lark-im 内容 |
| `auth qrcode --output <绝对/沙箱外路径>` | 1 | 1 (014) | unsafe output path改相对路径重试成功 | 路径约束在 lark-shared不可改 |
| `im +messages-search` | 2 | 1 (015) | bot exit2 + `--as user` exit2 | 该命令 user-onlySKILL 表已注明bot 身份必败agent 没看清就猜 |
| `im +chat-messages-list --page-all` | 1 | 1 (015) | exit2无过滤 page-all | 见下「015 token 黑洞」 |
- **解读**:本轮**没有一条 lark-im 命令因「参数名/类型写错」系统性失败**。080 三条命令 0 失败014 的失败全在跨域 contact + auth015 的失败集中在 messages-searchuser-onlybot 必败)与无过滤 page-all。**没有 lark-im 侧常规「报错/参数整形」工单**——与 token 减体积方向一致,本轮抓手是减体积不是补内容。
## 015 的 token 黑洞(与 round-2 一致,复述以免被误当成 token 抓手)
- 015 真正的 token 大头**不是任何 lark-im doc**,而是 **block #19一次 `Read` 工具读入 22,556 tok占该题 visible 51.5%**。成因链:#12/#17 `+messages-search`/`--page-all` exit2 → #18 退到 `+chat-messages-list`(无过滤)→ 输出 43.5KB 被持久化 → agent `Read` 整文件 → 22.5k tok 灌进上下文 → 再靠本地 grep(#2733) 抠出「飞豆」两条。
- **从文档角度**`chat-messages-list.md` 本题 reach=0状态①调用前从没读它本写了 `--start/--end``--page-size` 等可避免全量拉取的约束。**但补它=增常驻/触达内容,与本轮降 token 目标方向相反**(见方向冲突);且 22.5k 是**单次工具输出**(强依赖该群消息量,单题不可外推),不是稳定常驻热点。**结论:观察项,交评测裁决,不作为本轮 token 抓手。**
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
> 对每条相关 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash本轮 3 题均未跑任何 `--help`)。
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错/卡 | 主导态 → 改动方向 |
|---|---|---|---|---|---|
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | — | 三题调用前都读了014 仍卡(环境,非内容);不可改 |
| `chat-create.md` | 0.667 | 0 | 0 | — | 080 调用前读→建群成功014 调用前读→授权阻断(非 reference 错)。**非触达问题,纯减体积** |
| `messages-send.md` | 0.333 | 0 | 0 | — | 080 调用前读→发卡片成功。**非触达问题**(已收割) |
| `chat-messages-list.md` | 0.0 | 1 (015) | 0 | — | ① 015 调用前从没读→`--page-all` 全量拉取→token 黑洞。触达缺口,但补它=增 token与目标冲突 |
| `messages-search.md` | 0.0 | 1 (015) | 0 | — | ① 015 从没读,直接猜 `+messages-search` ×2 → exit2user-onlybot 必败) |
- **结论****本轮没有「该前置」的干净 case**。RC-1chat-create.md 减体积)是「调用前已读、内容够用 → 去冗余」的纯 token 减法不涉及触达。015 的两处 ① 触达缺口确实存在,但修它们=增内容、与降 token 目标相反,且 015 已 PASSbot + 本地 grep 兜底)——属观察项,**不要被诱导去推前置**。
## 方向冲突记录(硬性约束 7
- **减体积RC-1 chat-create.md与 objective.direction 同向)** vs **补收窄/前置指引(修 015 chat-messages-list 全量灌入,与 objective 反向)**:前者降按需 token后者为省「单次工具输出」反而要**增**文档常驻 token。两者方向相反**不可合并**。本轮目标是降 token取减体积一侧015 全量灌入作为观察项记录、不作为要补的内容根因。
## 差距台账复盘
- 无(`discard-ledger.json` 为空,无已跑未采纳候选)。
## 逐 case
### 1 (014) [workorder=PASS / 实质 FAIL] token=34,555(reported)/visible 17,364 耗时=37s 命令失败率=5/7 维度=效果(不可修)
- 判分点结果3 条全 ✗——建群/拉人/发消息全未发生,卡在 `contact +search-user` 解析 open_iduser 授权阻断 + 跨域 contact。verdict=PASS 系聚合口径,按判分点证据当 FAIL 处理。
- 命令失败5/7。`contact +search-user` bot exit2 ×2、`--as user` token_missing ×2`auth qrcode` 绝对路径 unsafe ×1改相对路径成功。**全部非 lark-im 命令**。
- 可发现性时序:#4 读 SKILL.md 正文(3,456) + #6 读 lark-shared(3,751跨 skill) + #7 读 chat-create.md(3,062调用前已读);失败在更上游的跨域 contact + 授权。**非 lark-im 触达问题**。
- token 归因SKILL.md 正文 3,456常驻静态19.9%+ lark-shared 3,751**跨 skill不归因 lark-im**+ chat-create.md 3,062按需17.6%**本题读了没用上**——授权阻断没走到建群)+ 系统 Skill 列表注入 4,612固定开销不归因。lark-cli 命令累计含多次短失败回显,单条都短、非热点。
- 耗时归因:本题往返多(查联系人→切 contact→失败→auth status→授权→qrcode 重试);多为授权链路 + 跨域固有串行 + 反应式重试duration 弱信号,需多轮复现)。
- 文档根因:效果=沙箱 user 授权 + 跨域 contact环境**无 lark-im 文档根因,本轮不改**token=chat-create.md 按需冗余RC-1但本题读了没用上收益只在 080 这种走通题里兑现)+ SKILL.md 常驻RC-3风险高、次级
### 2 (015) [PASS·真] token=54,568(reported)/visible 43,760 耗时=2m5s 命令失败率=3/11 维度=token但大头非 lark-im doc
- 判分点结果3/3 ✓——定位群、转发「飞豆」消息、@傅六铭知会全部成功(两次 `messages-send``ok:true`)。**全程 bot 身份,无授权阻断**。
- 命令失败3/11。`+messages-search` bot exit2、`+messages-search --as user` exit2、`+chat-messages-list --page-all` exit2无过滤agent 退到无 page-all + 本地 grep 兜底成功。(#14 `--page-all | grep` 返回空属「成功但无命中」,非硬失败,未计入。)
- 可发现性时序:① `messages-search.md` / `chat-messages-list.md` 调用前从没读reach=0直接猜命令。**本题未读任何 lark-im reference**,故 lark-im reference 的体积与本题 token 无关。
- token 归因:**本题 token 大头不是 lark-im doc**,是 block #19 一次 `Read` 持久化文件 = **22,556 tok51.5%,归「其他工具调用/返回」)**,成因=`--page-all` 无过滤全量拉取→43.5KB→Read 灌入(**单次输出**可信度强依赖该群消息量。SKILL.md 正文 3,448常驻。lark-shared 3,749跨 skill不归因。**RC-1 改 chat-create.md 对本题 token 无影响**(本题没读它)。
- 耗时归因:本题最长(2m5s),主因 messages-search 连环失败→改 page-all→大输出→多次本地 grep 抠数据的多轮往返duration 弱信号model calls 16/raw 32明显高于 080作旁证
- 文档根因token 黑洞的放大器=`chat-messages-list.md` 没被读到(状态①)+ SKILL.md 表未提示大群应 server-side 收窄——但**补这条与降 token 目标相反**(方向张力),列为观察项;本题已 PASS。本轮 token 抓手RC-1不落在本题。
### 3 (080) [PASS·真] token=38,009(reported)/visible 21,599 耗时=47s 命令失败率=0/3 维度=token
- 判分点结果3/3 ✓——`auth status` 见 bot ready→主动选 bot→建群 `ok:true`→发 interactive 卡片 `ok:true`。**任务完整完成,零命令失败**。
- 命令失败0/3。三条 lark-cliauth status / chat-create / messages-send全成功。
- 可发现性时序:#4 读 SKILL.md 正文(3,455) + #6 读 lark-shared(3,751跨 skill) + #9 读 chat-create.md(3,060) + #10 读 messages-send.md(5,365旧版) ,全部状态③(调用前已读且用上)。**无触达问题。** 实际只用了 `+chat-create --name … --format json` 的最简形态——没用两步流/owner/members/topic/error-recovery。
- token 归因:**本题是纯 token 抓手题**——读取 Skill 占 56.4%messages-send.md 5,365trace 旧版,**当前已被 round-2 压到 2,006本轮不再可压**+ SKILL.md 正文 3,455常驻RC-3+ chat-create.md 3,060按需**RC-1当前 2,336本轮唯一干净抓手**)。系统 Skill 列表注入 4,612固定开销不归因。lark-shared 3,751跨 skill不归因
- 耗时归因47s全部为正常建群+发卡片串行,无重试、无写后回查(无离群)。
- 文档根因无效果根因已绿token=RC-1chat-create.md 内部冗余,本题是其收益唯一稳态兑现题)+ RC-3SKILL.md 常驻,风险高、次级)。**本题 token 杠杆最清晰且 effect 风险可控**(命令全成功,压 chat-create.md 的示例/场景/报错镜像不碰 080 实际用到的最简建群链路)。
## 给 candidate-writer 的收口(不含具体改法)
- **唯一在 T1 内还没被收割的干净 token 抓手是 RC-1`chat-create.md` 内部冗余)**Commands 12 例过度罗列 + Usage Scenarios 3 场景重复 Commands + Common Errors 9 行部分镜像 validation 字符串——**与 round-2 已采纳的 messages-send.md 压缩同型**,粗估可省 ~600800 raw tok约 30%。reach=0.667014+080降幅在子集计入。
- **载重红线**AI Usage Guidance 的 232043 两步流 + `succeed_type=1` + `--chat-mode topic` 区分 + `--owner` 默认,是 080/014 走通 bot 建群的语义依据,**压缩中不可误删**——压的是示例/场景/报错镜像体积,不是规则。
- **RC-2 已收割**messages-send.md round-2 已 5,365→2,006trace 里的 5,365 是历史值,**不要重复提**。
- **RC-3SKILL.md 常驻)是次级且风险更高**round-1 已压过一刀,剩余多为全域 identity/路由/约束,删错碰坏 015/080 已走通的 bot 身份与命令路由——不作首选。
- **不要推前置**:本轮没有「该前置」的干净 case。015 的两处 ① 触达缺口chat-messages-list/messages-search 没读)虽真实,但修=增内容、与降 token 反向,且 015 已 PASS——属观察项。
- **effect 不可在本轮 T1 内合法抬升**014 是环境(沙箱不能扫码)+ 跨域 contact无 lark-im 文档根因015/080 已真 PASS。effect deltas 视作 auth-noise不追。
- **干净 token 抓手接近见底(诚实判断)**reach>0 集合三个文件中messages-send.mdround-2与 SKILL.mdround-1已各压一刀**chat-create.md 是最后一个未动过的干净文件**。压完它之后T1 内 reach>0 的纯冗余(罗列/重复/--help 镜像)基本耗尽;再往下只剩 (a) 高 effect 风险的 SKILL.md 载重内容,或 (b) reach=0 的 22 个盲区 reference压了也不在判决集、无法被采纳。**本轮 RC-1 很可能是这条优化路径上最后一个低风险、可被采纳的 token 抓手。**
- **缺失信息doc_fix_hint 语气,非药方)**chat-create.md 把同一组 flag 在 Commands(12 例) 与 Usage Scenarios(3 场景) 重复演示、Common Errors 多行复述 `--help`/报错本身就会吐的 validation 字符串——这类「枚举/重复/镜像、低增量」内容是其 token 的主要去处,且是减法(删冗余)而非加法。
## 数据缺口
1. **trace 版本错位(最关键)**:本轮 trace=round-1 旧版 child-runsmessages-send.md 在 trace 里仍是 5,365round-2 已压到 2,006。所有「当前文件体积」结论我已用 ai-tokenizer 实测当前工作树校正SKILL.md 3,525 / chat-create.md 2,336 / messages-send.md 2,006但**单题行为与 reach 仍来自旧 trace**——若 round-2 改动改变了 080/014 的读取行为,需以实际 round-3 eval-run 复核。
2. **RC-1 子集小**chat-create.md reach=0.667 但实际吃到压缩收益的稳态题只有 080014 读了没用上、授权阻断),证据基数=1降幅需评测在子集确认。
3. **015 的 22.5k 黑洞是单次工具输出**,强依赖该群消息量,非稳定常驻热点,单题不可外推;且与降 token 目标方向冲突,不作抓手。
4. **duration 三题波动大**37s/2m5s/47s015 长尾主因 messages-search 连环失败+大输出多轮抠数据单轮不足定论需多轮复现。model calls(8/16/6) 比 wall-clock 稳,可作旁证。
5. **工具调用口径不一致**trend.json 的 R1 tool_calls=26.3、R2=10与 session-analyze 的 model calls(8/16/6) 口径不同(趋势表疑似含 raw 计数);旁证以 timeline 实际往返为准。趋势看R1→R2 命令失败率 0.60→0.35、tool_calls 26→10 明显下降,但那主要是 effect 从「三题全卡授权」变成「2 真 PASS + 1 卡」带来的,**不是 token 改动的功劳**token 均值 R1 31,997→R2 42,377 上升,主因是 R2 只评 080单题大口径差异 + 015 黑洞,非文档常驻变重——趋势对 token 轴判读价值有限,以单题 session-analyze 为准。
6. **effect 维度全部归因为「无文档根因/不可修」**014 跨域+环境015/080 已绿。本轮 effect 无 T1 可发力点deltas 视作 auth-noise。

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,220 @@
{
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
}

View File

@@ -0,0 +1,18 @@
{
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-chat-create.md",
"tier": "T1",
"round_index": 3,
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"reason": "纯瘦身改动对抗式逐项核验未发现可证伪点14 条承重红线232043 两步流/succeed_type=1/chat-mode topic 与 thread 区分/--owner 默认/set-bot-manager/chat.members create/Output Fields/scopes在改后文件全部 grep 命中Scenario 3 建群→欢迎语 recipe 逐字保留仅换标题、搬迁到同文件 AI Usage Guidance 末尾未删;删掉的 6 行 Common Errors 已在 shortcuts/im/im_chat_create.go 源码核实是 CLI 原样回显的确定性 validation 字符串(运行时报错可复得,非仅靠 --help删掉的命令例均为单 flag 变体且 flag 仍全列于 Parameters 表;字节 7996→6450(-19.3%)/词 1258→969(-23%) 为真实删减、无增读拉力、recipe 在同文件内搬迁不引发额外读;单根因 RC-1本文件内部冗余strategy 明确不捆 RC-2/RC-3。",
"dimensions": {
"reward_hack": {"pass": true, "evidence": "无硬编码评测答案/资源名/ID未对 080 的 --name --format json 最简建群链做特判080 链路一环未碰;属通用『删运行时另有出处的重复』瘦身,与 round-2 messages-send 同型同纪律,非针对某几题"},
"semantic_regress": {"pass": true, "evidence": "14 条承重红线改后文件全部命中Scenario 3 recipe 逐字保留(仅换标题、搬入 AI Usage Guidance删的 6 行报错经 im_chat_create.go 核实为 CLI verbatim validation运行时可复得删的命令例均单 flag 变体、flag 仍全列于 Parameters 表,无承重内容丢失"},
"token_shift": {"pass": true, "evidence": "真实删减 bytes 7996→6450(-19.3%)、words -23%;纯删除无新增前置/『先读 X』拉力welcome recipe 在同一文件内搬迁不触发额外读;唯一 --help 指针仅覆盖 Parameters 表已列的单 flag 变体,非强制增读。运行时每题 context 只降不升"},
"contract_break": {"pass": true, "evidence": "T1 文档无对外契约;结构完整(仅 Usage Scenarios 段2 重复删、recipe 搬迁),所有 ## 章节与 References 链接保留,无断链/缺章"},
"devguide": {"pass": true, "evidence": "对照 review-rubric 优化红线semantic_regress / contract_break 两维未删承重、未破坏结构reference 收敛到 gotcha-only、与 --help/Parameters 重复内容下沉为指针,符合 optimization-playbook 的『单命令示例下沉、与 --help 重复留一处其余指针』annotation 三段均标 R1 落在可重构范围、未触 R3 的 AI Usage Guidance prose"},
"single_root_cause":{"pass": true, "evidence": "diff 只服务 RC-1本文件内部『示例罗列+场景重复+报错镜像』三类冗余),全为同一根因下的去重;未捆 RC-2(messages-send)/RC-3(SKILL.md),未夹带语义独立的承重删除,无多根因对冲叙事"}
}
}

View File

@@ -0,0 +1,394 @@
{
"round": 3,
"status": "admitted",
"parent_id": "557349b40feb359bb791749a37571d59edb7e72e",
"parent_worktree": "/Users/bytedance/Projects/cli",
"child_worktree": "/Users/bytedance/Projects/cli",
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
"module": "skills/lark-im/references/lark-im-chat-create.md",
"candidate_modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"module_reach": {
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
},
"expected_reach": {},
"minibatch": [
"1",
"2",
"3"
],
"pareto_cases": [
"1",
"2",
"3"
],
"artifacts": {
"workorder": "workorder.md",
"diagnosis": "diagnosis.md",
"attribution": "attribution.json",
"strategy": "strategy.md",
"review": "review.json",
"trend": "trend.json"
},
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"signature": "53194d7a111df326cc078b633f43587225bd0132",
"tier": "T1",
"intent": "dedup Commands<->Usage Scenarios overlap + compress --help-mirroring Common Errors in chat-create.md; keep all red lines (232043 two-step,succeed_type=1,chat-mode topic,--owner)",
"target_axis": "token",
"changed_files": [
"skills/lark-im/references/lark-im-chat-create.md"
],
"decision_basis": {
"type": "module",
"module": "skills/lark-im/references/lark-im-chat-create.md"
},
"decision_cases": [
"1",
"3"
],
"review": {
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-chat-create.md",
"tier": "T1",
"round_index": 3,
"base_commit": "572eb8da41f608bd93b25916cac02cb772825b97",
"code_tip": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"reason": "纯瘦身改动对抗式逐项核验未发现可证伪点14 条承重红线232043 两步流/succeed_type=1/chat-mode topic 与 thread 区分/--owner 默认/set-bot-manager/chat.members create/Output Fields/scopes在改后文件全部 grep 命中Scenario 3 建群→欢迎语 recipe 逐字保留仅换标题、搬迁到同文件 AI Usage Guidance 末尾未删;删掉的 6 行 Common Errors 已在 shortcuts/im/im_chat_create.go 源码核实是 CLI 原样回显的确定性 validation 字符串(运行时报错可复得,非仅靠 --help删掉的命令例均为单 flag 变体且 flag 仍全列于 Parameters 表;字节 7996→6450(-19.3%)/词 1258→969(-23%) 为真实删减、无增读拉力、recipe 在同文件内搬迁不引发额外读;单根因 RC-1本文件内部冗余strategy 明确不捆 RC-2/RC-3。",
"dimensions": {
"reward_hack": {
"pass": true,
"evidence": "无硬编码评测答案/资源名/ID未对 080 的 --name --format json 最简建群链做特判080 链路一环未碰;属通用『删运行时另有出处的重复』瘦身,与 round-2 messages-send 同型同纪律,非针对某几题"
},
"semantic_regress": {
"pass": true,
"evidence": "14 条承重红线改后文件全部命中Scenario 3 recipe 逐字保留(仅换标题、搬入 AI Usage Guidance删的 6 行报错经 im_chat_create.go 核实为 CLI verbatim validation运行时可复得删的命令例均单 flag 变体、flag 仍全列于 Parameters 表,无承重内容丢失"
},
"token_shift": {
"pass": true,
"evidence": "真实删减 bytes 7996→6450(-19.3%)、words -23%;纯删除无新增前置/『先读 X』拉力welcome recipe 在同一文件内搬迁不触发额外读;唯一 --help 指针仅覆盖 Parameters 表已列的单 flag 变体,非强制增读。运行时每题 context 只降不升"
},
"contract_break": {
"pass": true,
"evidence": "T1 文档无对外契约;结构完整(仅 Usage Scenarios 段2 重复删、recipe 搬迁),所有 ## 章节与 References 链接保留,无断链/缺章"
},
"devguide": {
"pass": true,
"evidence": "对照 review-rubric 优化红线semantic_regress / contract_break 两维未删承重、未破坏结构reference 收敛到 gotcha-only、与 --help/Parameters 重复内容下沉为指针,符合 optimization-playbook 的『单命令示例下沉、与 --help 重复留一处其余指针』annotation 三段均标 R1 落在可重构范围、未触 R3 的 AI Usage Guidance prose"
},
"single_root_cause": {
"pass": true,
"evidence": "diff 只服务 RC-1本文件内部『示例罗列+场景重复+报错镜像』三类冗余),全为同一根因下的去重;未捆 RC-2(messages-send)/RC-3(SKILL.md),未夹带语义独立的承重删除,无多根因对冲叙事"
}
}
},
"child_k": 5,
"eval_trace": null,
"retro": {
"cause": "已入池",
"noise_borderline": false,
"summary": "越带入池,无需复盘补发"
},
"retro_sessions": [
{
"case": "1",
"session": "harness-opt/rounds/round-003/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 34270,
"child": 33840,
"gain": "收益现",
"pass_delta": null
},
{
"case": "3",
"session": null,
"axis": "token",
"expect": "降",
"parent": 35478,
"child": 35942,
"gain": "反向",
"pass_delta": null
}
],
"verdict": "admitted",
"ci": null,
"new_candidate": "53194d7a111df326cc078b633f43587225bd0132",
"decision": {
"parent_success": 1.0,
"child_success": 1.0,
"parent_score": 0.8,
"child_score": 1.0,
"score_saved": 0.19999999999999996,
"score_threshold": 0.09532271373123208,
"parent_token": 34874.0,
"child_token": 34891.0,
"saved": -17.0,
"threshold": 4532.708313776408,
"parent_duration": 45267.5,
"child_duration": 43656.0,
"dur_saved": 1611.5,
"dur_threshold": 4899.200953624988,
"dur_margin": 1.0,
"missing_duration": [],
"k_child": 5,
"k_parent": 5,
"decision_n": 2,
"missing_context": [],
"missing_score": [],
"parent_token_acc": 248146.5,
"child_token_acc": 235911.0,
"phi0_score": 0.5666666666666667,
"eff_margin": 1.0,
"parent_token_full": 34874.0,
"child_token_full": 34891.0,
"saved_full": -17.0,
"observe_n": 2,
"target_axis": "token",
"admitted": true,
"reason": "score_gain"
},
"patch": "verify_results/round-003-lark-im-references-lark-im-chat-create.patch"
}

View File

@@ -0,0 +1,43 @@
# Round 3 候选策略(模块=references/lark-im-chat-create.md, tier=T1, 主指标=token
## 根因与选择
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|---|---|---|---|---|---|---|
| RC-1 chat-create.md 内部「示例罗列+场景重复+--help 镜像」三类冗余 | 评测归因 + 规范经验(双视角同指) | references/lark-im-chat-create.md(0.667) | Commands/Scenarios/Errors 段=R1 | 中014+080 子集,稳态兑现仅 080 | P0 | ✅ |
| RC-2 messages-send.md 内部冗余 | 评测归因 | references/lark-im-messages-send.md(0.333) | — | — | — | round-2 已收割,不再是抓手) |
| RC-3 SKILL.md 常驻正文 | 评测归因 | SKILL.md(1.0) | 多为 R2/R3 路由·identity | — | — | round-1 已压一刀;剩余多为全域路由/身份effect 风险高,不选) |
- 选中理由RC-1 是 reach>0 集合里**唯一从未被任何轮收割的干净文件**2023 至今原样),且其冗余型态与 round-2 已采纳并 PASS 的 messages-send.md 完全同型(罗列+重复+--help 镜像。RC-2 已在 round-2 收割5,365→2,006trace 里的 5,365 是历史值RC-3 是 round-1 已动过的同一文件、剩余多为全域 identity/路由(删错碰坏 015/080 已走通的身份与路由判断effect 风险高于 RC-1故不选。
- 选模块理由chat-create.md reach=0.667014+080 调用前都读到,状态③,非触达问题——纯减体积场景);它正是承载 RC-1 的文件。未选 reach=0 的 22 个盲区 reference改了也不在判决集、无法被采纳触 reach 锁)。
- 规范经验源补注双视角同指一处。视角②skill-annotations独立把 Commands(L11-50)/Usage Scenarios(L120-143)/Common Errors(L144-158) 全标为 **R1可重构**,把 AI Usage Guidance(L70-98) 标为 **R3需强理由**——与归因的「压示例/场景/报错镜像、绝不碰 232043 两步流」完全吻合。对照 reviewer optimization-playbook单命令用法/示例属 USAGE→下沉 `--help`;与 `--help` 重复的 validation 字符串「留命中率最高一处,其余删/指针」。当轮 eval 可在 080 子集裁出 token 真伪080 调用前读、建群成功),但稳态收益基数仅 1 题014 读了没用上)——敞口已在「预期效果」标明。
## 改了什么(逐处)
- **Commands(L12-50)** — 12 个 bash 示例(多条仅差一个 flag压成 5 个差异化示例 + 一行 `--help` 指针。之前→之后:删掉 `--owner`/`--users`/`--bots`/`--as bot`/`--as user`/`--dry-run`/`--format json` 各单独一例(信息已在 Parameters 表合并为「invite members+owner 一例」「bot+set-bot-manager 一例」,单 flag 变体一行指针带过(含 `--dry-run` 语义保留)。
- **Usage Scenarios(L120-143)** — 整段 3 场景删除/搬迁。Scenario 1owner、Scenario 2users+bots+owner重复 Commands 与 Parameters 已展示的 flag 组合 → 删Scenario 3建群→发欢迎语链是独有 recipe → 搬进 AI Usage Guidance 末尾「Create a group, then send a welcome message」保留。
- **Common Errors(L144-158)** — 9 行压成 2 行。删掉 6 行直接复述 CLI 确定性 validation 字符串的行(`--name`/`--description` 超长、`--users`/`--bots` 超数、3 条 `ou_xxx`/`cli_xxx` 格式错)——这些 `--help`/报错本身原样吐出改为一句「format/limit validation 由 CLI 原样回显limits 见 Parameters 表」的指针;**保留**需要额外动作的 2 行Permission denied(99991672) 给 console action、`bot is invisible(232043)` 指回两步流。
## 为什么这么改(机制)
- **省 context 的因果**chat-create.md 是 lazy reference读到即整文件进窗口080/014 reach=0.667)。删掉的全是运行时另有出处(`--help`/Parameters 表)或本段内重复的内容——示例的 flag 组合 = Parameters 表已列validation 字符串 = CLI 报错原样吐。删后 Agent 仍能:经 SKILL.md 选对 `+chat-create`、经 Parameters 表/`--help` 补全 flag 用法、遇 232043 走两步流。即 optimization-playbook §13 核心判据「删掉后 Agent 是否仍能选对命令并补到用法」——成立。
- **规范经验源**optimization-playbook「reference 收敛到 gotcha-only不是 --help 镜像」「单命令用法/示例→下沉」「与 --help 重复→留一处其余指针」content-taxonomy 单命令示例=R1 下沉、与 --help 重复=R0/指针。annotation 三段独立标 R1本改动落在 R1 重构范围内,未触 R3 段。
## 预期效果
- 成功率effect硬门槛**不退化**。所有 effect 红线逐条保留并已 grep 校验见下「刻意没做什么」。080 实际只用 `--name --format json` 最简建群链——本改动未碰该链路任何一环014 卡在跨域 contact+授权(非本 reference
- context分两层
- **(1) 静态字数差**bytes 7996→6450-19.3%、chars -19.5%、words -23.0%、tiktoken(cl100k 代理) 2125→1714(-19.3%)。换算到 diagnosis 用的 ai-tokenizer 基线OLD=2336 raw**预计 NEW ≈ 18001900 raw tok省 ~450540 tok约 1923%**。
- **(2) 运行时 context 方向**:对**读到 chat-create.md 的题080及理论上 014下降** ~450540 tok对没读它的题015**无影响**015 大头是单次 `Read` 22.5k 工具输出,与本 reference 无关)。本改动是纯删减、无新增前置/增读拉力,不会抬升运行时 token。
- **与 direction 一致**objective=降 token无张力。
- **覆盖敞口(诚实标注)**:稳态吃到收益的题只有 080 一题014 读了没用上、授权阻断未走到建群),证据基数=1且本轮派工单 trace 是 round-1 旧版 child-runs单题读取行为需 round-3 实跑 eval 在 014+080 子集复核。实际降幅(~450540略低于 diagnosis 的 ~600800 估计——因我**刻意保留**了 AI Usage Guidance 全段 proseR3+ 完整 Parameters/Output Fields 表(载重),用一点压缩头寸换零 effect 风险。
## 刻意没做什么(反 reward-hack / 反过拟合)
- 没硬编码任何评测题答案;没删任何承重内容;没碰本 skill 以外的文件、没把无关根因捆进本轮。
- **逐条保留的载重红线(已 grep 校验存在)**
- 232043 两步流全段contact search → `--users 当前用户` 建群 → `chat.members create --as user` 加其他人 → 查 `invalid_id_list`
- `succeed_type=1` 语义解释;
- `--chat-mode topic` vs 「普通群 + `group_message_type=thread`」的区分注解;
- `--owner` 默认行为bot 身份默认 bot / user 身份默认授权用户);
- 全部 flag`--set-bot-manager``--dry-run``--type public``--users/--bots` 上限与格式、identity/scope 指引、互斥与护栏规则、Output Fields 全表。
- 本改动**不是**按评测错误分布反推的拟合型改动——它是「删运行时另有出处的重复/镜像」的通用瘦身,与 round-2 messages-send.md 同型同纪律;非针对某几题的特判。
- 未做 RC-3SKILL.md 进一步压缩):剩余多为全域 identity/路由,删错有 effect 风险,超出本轮低风险抓手范围。未做 015 的前置补充:那是增内容、与降 token 反向方向冲突diagnosis 已记录)。
## 签名
- signature: 见 commit shagit diff: 18 insertions / 60 deletions on lark-im-chat-create.md tier: T1

View File

@@ -0,0 +1,20 @@
[
{
"round": 1,
"n": 3,
"pass_n": 0,
"cmd_fail_rate": 0.6,
"tool_calls": 26.333333333333332,
"duration_ms": 50189.0,
"token": 31997.0
},
{
"round": 2,
"n": 3,
"pass_n": 3,
"cmd_fail_rate": 0.3466666666666667,
"tool_calls": 10.0,
"duration_ms": 69666.66666666667,
"token": 42377.333333333336
}
]

View File

@@ -0,0 +1,44 @@
# Round 3 归因派工单parent=557349b40feb359bb791749a37571d59edb7e72e模块未定由 candidate-writer 据诊断点名)
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer+ 逐题结构化 `attribution.json`(给 dashboard。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置正该选来修——不是白烧reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3']
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3']
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.333;判决集(实测∪预期): ['3']
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
## 逐轮诊断信号趋势(纯诊断,不进判决)
| 轮 | 题数 | PASS | 命令失败率 | 工具调用 | 耗时(ms) | token |
|---|---|---|---|---|---|---|
| R1 | 3 | 0 | 0.60 | 26 | 50189 | 31997 |
| R2 | 3 | 3 | 0.35 | 10 | 69667 | 42377 |
> 跨题均值,按轮排。**命令失败率、工具调用数是横切诊断信号,不是准入轴**(准入只走 效果/token/耗时)——用来判「上一轮那刀有没有把失败/轮次压下去」。工具调用数比 wall-clock 稳,可给噪声大的耗时轴当旁证。
### 1 [PASS] ctx=34270 (acc=274608) 43995ms tools=31
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✗ 使用当前用户身份创建名为「IM合作群」的群聊
证据: transcript 在展示授权二维码后结束,无任何 `lark-cli im +chat-create` 调用。执行停在 '授权完成后请告诉我,我会继续帮你创建群聊并发送消息',群聊未创建。
✗ 将傅一铭和傅二铭加入该群
证据: transcript 显示尝试搜索用户时遇到 `need_user_authorization` 错误,授权流程启动后中断。未获取到任何用户的 open_id无后续添加操作。
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
证据: 群聊未创建,无 chat_id 可返回。transcript 无任何 `lark-cli im messages-send` 调用。
### 2 [PASS] ctx=47116 (acc=612048) 114310ms tools=49
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 成功定位名为「fusanming_at_openclaw群」的群并获取最近包含「飞豆」关键字的消息
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功
### 3 [PASS] ctx=35478 (acc=221685) 46540ms tools=22
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
✓ 将该卡片发送到新建群中,预期返回 message_id

File diff suppressed because it is too large Load Diff

20
harness-opt/trend.json Normal file
View File

@@ -0,0 +1,20 @@
[
{
"round": 1,
"n": 3,
"pass_n": 0,
"cmd_fail_rate": 0.6,
"tool_calls": 26.333333333333332,
"duration_ms": 50189.0,
"token": 31997.0
},
{
"round": 2,
"n": 3,
"pass_n": 3,
"cmd_fail_rate": 0.3466666666666667,
"tool_calls": 10.0,
"duration_ms": 69666.66666666667,
"token": 42377.333333333336
}
]

View File

@@ -0,0 +1,152 @@
From 237a77feb341e15656386d6952a875dc459fec8c Mon Sep 17 00:00:00 2001
From: "zhangheng.023" <zhangheng.023@bytedance.com>
Date: Tue, 23 Jun 2026 18:27:25 +0800
Subject: [PATCH] =?UTF-8?q?opt(round-001):=20SKILL.md=20=E2=80=94=20fold?=
=?UTF-8?q?=20USAGE=20method-index=20+=20scope=20table=20into=20a=20schema?=
=?UTF-8?q?=20pointer=20(-40%=20resident=20tokens)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
skills/lark-im/SKILL.md | 122 +++-------------------------------------
1 file changed, 8 insertions(+), 114 deletions(-)
diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md
index bc39aae1..ac1c6900 100644
--- a/skills/lark-im/SKILL.md
+++ b/skills/lark-im/SKILL.md
@@ -110,122 +110,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination |
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination |
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id |
+| `reactions.*` (add / delete / list / batch_query) | Add, remove, or read emoji reactions on a message; user/bot; caller must be in the conversation, and can only delete its own reactions. Read first: [`lark-im-reactions.md`](references/lark-im-reactions.md) |
+| `feed.groups.*` (create / update / delete / batch_query / batch_add_item / batch_remove_item) | Manage feed groups (tags) and their member cards; user-only. Read first: [`lark-im-feed-groups.md`](references/lark-im-feed-groups.md) |
-## API Resources
+## Native API (beyond shortcuts)
+
+Anything not covered by a shortcut above (e.g. `chats.*`, `chat.members.*`, `chat.managers.*`, `chat.moderation.*`, `chat.user_setting.*`, `messages.delete|forward|merge_forward|read_users|urgent_*`, `threads.forward`, `images.create`, `pins.*`) is callable as a raw API:
```bash
-lark-cli schema im.<resource>.<method> # 调用 API 前必须先查看参数结构
-lark-cli im <resource> <method> [flags] # 调用 API
+lark-cli schema im.<resource>.<method> # MUST run first — gives params, identity (user/bot/tenant), and required scope
+lark-cli im <resource> <method> [flags] # then call
```
-> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
-
-### chats
-
- - `create` — 创建群。Identity: `bot` only (`tenant_access_token`).
- - `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats.
- - `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats.
- - `update` — 更新群信息。Identity: supports `user` and `bot`.
-
-### chat.members
-
- - `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
- - `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
- - `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
- - `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
-
-### chat.user_setting
-
- - `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
- - `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
-
-### chat.managers
-
- - `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.
- - `delete_managers` — 删除群管理员。Identity: supports `user` and `bot`; only the group owner can remove managers; max 50 users or 5 bots per request.
-
-### chat.moderation
-
- - `get` — 获取群成员发言权限。Identity: supports `user` and `bot`; the caller must be in the target chat and belong to the same tenant.
- - `update` — 更新群发言权限。Identity: supports `user` and `bot`; only the group owner (or creator bot with `im:chat:operate_as_owner`) can update; the caller must be in the chat.
-
-### messages
-
- - `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
- - `forward` — 转发消息。Identity: supports `user` and `bot`.
- - `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
- - `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
- - `urgent_app` — 发送应用内加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
- - `urgent_phone` — 发送电话加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
- - `urgent_sms` — 发送短信加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
-
-### reactions
-
- - `batch_query` — 批量获取消息表情。Identity: supports `user` and `bot`.[Must-read](references/lark-im-reactions.md)
- - `create` — 添加消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
- - `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
- - `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
-
-### threads
-
- - `forward` — 转发话题。Identity: supports `user` and `bot`.
-
-### images
-
- - `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
-
-### pins
-
- - `create` — Pin 消息。Identity: supports `user` and `bot`.
- - `delete` — 移除 Pin 消息。Identity: supports `user` and `bot`.
- - `list` — 获取群内 Pin 消息。Identity: supports `user` and `bot`.
-
-### feed.groups
-
- - `batch_add_item` — Batch add feed cards to a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `batch_query` — Batch query feed groups. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `batch_remove_item` — Batch remove feed cards from a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `create` — Create a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `delete` — Delete a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- - `update` — Update a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
-
-## 权限表
-
-| 方法 | 所需 scope |
-|------|-----------|
-| `chats.create` | `im:chat:create` |
-| `chats.get` | `im:chat:read` |
-| `chats.link` | `im:chat:read` |
-| `chats.update` | `im:chat:update` |
-| `chat.members.bots` | `im:chat.members:read` |
-| `chat.members.create` | `im:chat.members:write_only` |
-| `chat.members.delete` | `im:chat.members:write_only` |
-| `chat.members.get` | `im:chat.members:read` |
-| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
-| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
-| `chat.managers.add_managers` | `im:chat.managers:write_only` |
-| `chat.managers.delete_managers` | `im:chat.managers:write_only` |
-| `chat.moderation.get` | `im:chat.moderation:read` |
-| `chat.moderation.update` | `im:chat:moderation:write_only` |
-| `messages.delete` | `im:message:recall` |
-| `messages.forward` | `im:message` |
-| `messages.merge_forward` | `im:message` |
-| `messages.read_users` | `im:message:readonly` |
-| `messages.urgent_app` | `im:message.urgent` |
-| `messages.urgent_phone` | `im:message.urgent:phone` |
-| `messages.urgent_sms` | `im:message.urgent:sms` |
-| `reactions.batch_query` | `im:message.reactions:read` |
-| `reactions.create` | `im:message.reactions:write_only` |
-| `reactions.delete` | `im:message.reactions:write_only` |
-| `reactions.list` | `im:message.reactions:read` |
-| `threads.forward` | `im:message` |
-| `images.create` | `im:resource` |
-| `pins.create` | `im:message.pins:write_only` |
-| `pins.delete` | `im:message.pins:write_only` |
-| `pins.list` | `im:message.pins:read` |
-| `feed.groups.batch_add_item` | `im:feed_group_v1:write` |
-| `feed.groups.batch_query` | `im:feed_group_v1:read` |
-| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` |
-| `feed.groups.create` | `im:feed_group_v1:write` |
-| `feed.groups.delete` | `im:feed_group_v1:write` |
-| `feed.groups.update` | `im:feed_group_v1:write` |
+> **MUST** run `schema` before any native call: it is the live source for the `--data` / `--params` structure, the supported identity (`--as user` vs `--as bot`), owner/admin/tenant constraints, and the required `im:*` scope — do not guess. On a missing-scope error, lark-cli returns a `console_url`; follow the lark-shared permission-handling flow.
--
2.50.1 (Apple Git-155)

View File

@@ -0,0 +1,334 @@
From 82a099feafb45d101116f10230ce7c2f92fbcfe5 Mon Sep 17 00:00:00 2001
From: "zhangheng.023" <zhangheng.023@bytedance.com>
Date: Tue, 23 Jun 2026 19:17:24 +0800
Subject: [PATCH] =?UTF-8?q?opt(round-002):=20lark-im-messages-send.md=20?=
=?UTF-8?q?=E2=80=94=20consolidate=204x=20repeated=20content-flag=20rule,?=
=?UTF-8?q?=20compress=20media=20enumeration=20&=20--help-mirror=20section?=
=?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../references/lark-im-messages-send.md | 259 ++++--------------
1 file changed, 51 insertions(+), 208 deletions(-)
diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md
index 484c024f..32818909 100644
--- a/skills/lark-im/references/lark-im-messages-send.md
+++ b/skills/lark-im/references/lark-im-messages-send.md
@@ -1,10 +1,8 @@
# im +messages-send
-> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
+> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
-Send a message to a group chat or a direct message conversation. Supports both user identity (`--as user`) and bot identity (`--as bot`).
-
-This skill maps to the shortcut: `lark-cli im +messages-send` (internally calls `POST /open-apis/im/v1/messages`).
+Send a message to a group chat (`--chat-id oc_xxx`) or a direct message (`--user-id ou_xxx`). One step, supports `--as user` and `--as bot` (default `bot`). Maps to shortcut `lark-cli im +messages-send` (`POST /open-apis/im/v1/messages`).
## Safety Constraints
@@ -16,249 +14,94 @@ Messages sent by this tool are visible to other people. Before calling it, you *
**Do not** send messages without explicit user approval.
-When using `--as bot`, the message is sent in the app's name, so make sure the app has already been added to the target chat.
-
-When using `--as user`, the message is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
+- `--as bot` (TAT, scope `im:message:send_as_bot`): the message is sent in the app's name — the app must already be in the target chat or have a DM relationship with the target user.
+- `--as user` (UAT, scopes `im:message.send_as_user` + `im:message`): the message is sent as the authorized end user.
## Choose The Right Content Flag
-### Default Selection Rule For Agents
-
-- Prefer `--markdown` for headings, lists, links, summaries, reports, or Markdown-looking content.
-- Use `--text` for exact plain text: logs, code, indentation-sensitive text, or literal Markdown.
-- Use `--content` for exact `post` JSON, titles, multiple locales, cards, or unsupported structures.
-
-| Need | Recommended flag | Why |
-|------|------|------|
-| Send headings, lists, links, summaries, or reports | `--markdown` | Best default for lightweight formatting; converted to Feishu `post` JSON |
-| Send plain text exactly as written | `--text` | Preserves literal text; no Markdown conversion |
-| Precisely control the final payload | `--content` | You provide the exact JSON for `text` / `post` / `interactive` / `share_*` / media payloads |
-| Send image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads URLs, or cwd-relative local files automatically |
-
-### `--text` vs `--markdown`
-
-- Use `--markdown` for lightweight formatted messages.
-- Use `--text` for exact plain text, especially logs, code, indentation, or Markdown characters that should **not** render.
-- Use `--content` when `--markdown` is not enough, especially if you need exact `post` JSON, a title, multiple locales, cards, or unsupported rich structures.
-
-## What `--markdown` Really Does
-
-`--markdown` accepts Markdown-like input and converts it to the Feishu `post` payload required by the message API.
-
-The shortcut does all of the following before sending:
-
-1. Forces `msg_type=post`
-2. Resolves remote Markdown images like `![x](https://...)` by downloading and uploading them first
-3. Normalizes the Markdown for Feishu post rendering
-4. Wraps the result as:
-
-```json
-{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
-```
-
-This makes `--markdown` the simplest path for lightweight formatted messages.
-
-### Markdown Boundaries
-
-- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
-- It always becomes a `post` payload with a single `zh_cn` locale.
-- It does **not** let you set a `post` title. If you need a title, use `--msg-type post --content ...`.
-- Headings are rewritten:
- - `# Title` becomes `#### Title`
- - `##` to `######` are normalized to `#####` when the content contains H1-H3
-- Consecutive headings are separated with blank lines after heading normalization.
-- Block spacing and line breaks may be normalized during conversion.
-- Code blocks are preserved as code blocks.
-- Excess blank lines are compressed.
-- Already-uploaded `img_xxx` image keys are the most reliable Markdown image input.
-- Local paths in Markdown image syntax like `![x](./a.png)` are **not** supported and will not be auto-uploaded.
-- Remote URLs (`https://...`) will be auto-downloaded and uploaded at runtime; if the download or upload fails, the image is removed with a warning.
-
-If you need a title, multiple locales, cards, unsupported rich structures, or byte-for-byte post JSON control, use `--content` and provide the final JSON yourself.
+| Content | Flag | Why |
+|---|---|---|
+| Headings, lists, links, summaries, reports (lightweight formatting) | `--markdown` | Best default; converted to Feishu `post` JSON |
+| Exact plain text — logs, code, indentation, literal Markdown chars that must **not** render | `--text` | Preserves literal text; no conversion |
+| Exact `post` JSON, a `post` title, multiple locales, cards (`interactive`), `share_*`, or unsupported structures | `--content` | You provide the final JSON; it must match the effective `--msg-type` |
+| Image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Uploads URLs or cwd-relative local files automatically |
-### Image Constraint for `--markdown`
+These content flags (and the media flags) are **mutually exclusive** — pass exactly one. Media flags are also mutually exclusive with each other.
-When using `--markdown` with images, prefer pre-uploading via `images.create` and referencing `![alt](img_xxx)` for predictable results. Remote URLs may work but are not guaranteed.
+## `--markdown` Gotchas
-**Steps:**
+`--markdown` always forces `msg_type=post` (single `zh_cn` locale) and normalizes input for Feishu post rendering. Key boundaries (not full CommonMark/GFM):
-```bash
-# 1. Upload image to get image_key
-lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png
-# Returns: {"image_key":"img_v3_xxxx"}
-
-# 2. Use image_key in --markdown
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n![diagram](img_v3_xxxx)\n\nSee above for details.'
-```
+- **No `post` title** — if you need one, use `--content` with `post` JSON.
+- **Headings rewritten**: `# Title` → `#### Title`; `##``######` normalized to `#####` when content has H1H3. Code blocks preserved; excess blank lines compressed.
+- **Images**: pre-upload via `im images create` and reference `![alt](img_xxx)` for reliable results. Remote `https://` URLs are auto-downloaded+uploaded at runtime (removed with a warning if that fails). Local paths in `![x](./a.png)` are **not** supported and will not auto-upload.
-## Preserving Formatting
+## Preserving Exact Formatting
-If the message has multiple lines, indentation, code blocks, tabs, or many quotes/backslashes, prefer shell ANSI-C quoting with `$'...'` for either `--markdown` or `--text`.
-
-This is especially useful in `zsh` / `bash` because it lets you write `\n` explicitly instead of relying on the shell to preserve literal newlines.
-
-### When formatting must be preserved
-
-Use `--text` plus `$'...'`:
-
-```bash
-lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/im-docs\nAction: please check logs'
-```
+For multi-line text, indentation, code blocks, tabs, or many backslashes/quotes, use shell ANSI-C quoting `$'...'` so `\n` is written explicitly. Use `--text` + `$'...'` when the receiver must see the text exactly as entered:
```bash
-lark-cli im +messages-send --chat-id oc_xxx --text $'```bash\nmake test\nmake lint\n```'
+lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/x\nAction: check logs'
```
-Use this path when you want the receiver to see the text exactly as entered, not a converted Markdown post.
-
## Commands
```bash
-# Send a formatted update
+# Formatted update (Markdown → post)
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Update\n\n- item 1\n- item 2'
-# Send a plain one-line message
+# Plain one-line text
lark-cli im +messages-send --chat-id oc_xxx --text "Hello"
-# Equivalent manual JSON
-lark-cli im +messages-send --chat-id oc_xxx --content '{"text":"Hello"}'
-
-# Send to a direct message (pass open_id)
+# Direct message (pass open_id)
lark-cli im +messages-send --user-id ou_xxx --text "Hello"
-# Send multi-line text while preserving formatting
-lark-cli im +messages-send --chat-id oc_xxx --text $'Line 1\nLine 2\n indented line'
-
-# Send Markdown with an image (must pre-upload via images.create)
-lark-cli im images create --data '{"image_type":"message"}' --file ./screenshot.png
-# Use the returned image_key in the markdown content
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Status\n\n![screenshot](img_v3_xxxx)\n\nDone.'
-
-# If you need exact post structure, send JSON directly
+# Exact post structure with a title
lark-cli im +messages-send --chat-id oc_xxx --msg-type post --content '{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}'
-# Send a local image (uploaded automatically before sending)
-lark-cli im +messages-send --chat-id oc_xxx --image ./photo.png
-
-# Or send directly with an existing image_key
-lark-cli im +messages-send --chat-id oc_xxx --image img_xxx
+# Markdown with an image (pre-upload first)
+lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png # -> {"image_key":"img_v3_xxxx"}
+lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n![diagram](img_v3_xxxx)\n\nDone.'
-# Send a local file (uploaded automatically before sending)
+# Media (local files uploaded automatically; --video requires --video-cover)
+lark-cli im +messages-send --chat-id oc_xxx --image ./photo.png
lark-cli im +messages-send --chat-id oc_xxx --file ./report.pdf
-
-# Send a video (--video-cover is required as the cover)
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover ./cover.png
-lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover img_xxx
-
-# Send audio
lark-cli im +messages-send --chat-id oc_xxx --audio ./voice.opus
-# Use an idempotency key (same key sends only once within 1 hour)
-lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --idempotency-key my-unique-id
-
-# Preview the request without executing it
-lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry-run
+# Idempotency (same key sends only once within 1 hour) / preview without sending
+lark-cli im +messages-send --chat-id oc_xxx --text "Hi" --idempotency-key my-id
+lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhi' --dry-run
```
-## Media Input Rules
-
-- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
-- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
-- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
-
-## Parameters
-
-| Parameter | Required | Description |
-|------|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `--chat-id <id>` | One of two | Group chat ID (`oc_xxx`) |
-| `--user-id <id>` | One of two | User open_id (`ou_xxx`) for direct messages |
-| `--text <string>` | One content option | Plain text message. Use when exact text and formatting preservation matter. Automatically wrapped as `{"text":"..."}` |
-| `--markdown <string>` | One content option | Best default for lightweight formatted messages such as headings, lists, links, summaries, and reports. Internally converted to `post` JSON with Feishu-specific normalization |
-| `--content <json>` | One content option | Exact message content JSON string; use this when you need full control over `msg_type` and payload. The JSON must match the effective `--msg-type` |
-| `--image <path\|url\|key>` | One content option | Cwd-relative local image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
-| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
-| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically. **Must be paired with `--video-cover`** |
-| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
-| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
-| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
-| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
-| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
-| `--dry-run` | No | Print the request only, do not execute it |
-
-> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
->
-> **Video cover rule:** `--video` **must** be accompanied by `--video-cover`. Omitting `--video-cover` when using `--video` will fail validation. `--video-cover` cannot be used without `--video`.
-
-## Common Mistakes
-
-- Choosing `--text` for headings, lists, links, summaries, or reports. Use `--markdown`.
-- Choosing `--markdown` when you actually need exact plain text. If exact line breaks, spacing, logs, code, or literal Markdown characters matter, use `--text`, usually with `$'...'`.
-- Assuming `--markdown` supports every Markdown feature. It is converted into a Feishu `post` payload and normalized first.
-- Putting local image paths inside Markdown like `![x](./a.png)`. `--markdown` does not auto-upload those paths.
-- **Using local file paths inside Markdown image syntax** (e.g. `![x](./a.png)`) with `--markdown`. Local paths are not auto-uploaded and will not render as an image. Pre-upload via `images.create` to get an `image_key` instead.
-- Using `--content` without making the JSON match the effective `--msg-type`.
-- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
-- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
-
-## `content` Format Reference
+Run `lark-cli im +messages-send --help` for the full flag list and types. Load-bearing rules that `--help` may not make obvious:
+
+- **Media paths** accept an existing key (`img_xxx`/`file_xxx`), an `http(s)://` URL, or a **cwd-relative** local path. Absolute paths (e.g. `/tmp/x.png`) are rejected — run from the file's directory and pass `./x.png`. Upload and send use the same identity.
+- **`--video` must be paired with `--video-cover`** (image key/URL/local path); `--video-cover` cannot be used alone.
+- **`--msg-type`** is inferred from `--text`/`--markdown`/media flags; explicitly setting a conflicting type fails validation.
+
+## `content` Format Reference (for `--content`)
| `msg_type` | Example `content` |
-|----------|-------------|
+|---|---|
| `text` | `{"text":"Hello <at user_id=\"ou_xxx\">name</at>"}` |
| `post` | `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}` |
-| `image` | `{"image_key":"img_xxx"}` |
-| `file` | `{"file_key":"file_xxx"}` |
-| `audio` | `{"file_key":"file_xxx"}` |
-| `media` | `{"file_key":"file_xxx","image_key":"img_xxx"}` (video; `image_key` is the cover from `--video-cover` — **required**) |
-| `share_chat` | `{"chat_id":"oc_xxx"}` |
-| `share_user` | `{"user_id":"ou_xxx"}` |
-| `interactive` | Card JSON (see Feishu interactive card documentation) |
+| `image` / `file` / `audio` | `{"image_key":"img_xxx"}` / `{"file_key":"file_xxx"}` / `{"file_key":"file_xxx"}` |
+| `media` (video) | `{"file_key":"file_xxx","image_key":"img_xxx"}` (`image_key` is the **required** cover) |
+| `share_chat` / `share_user` | `{"chat_id":"oc_xxx"}` / `{"user_id":"ou_xxx"}` |
+| `interactive` (card) | Card JSON (see Feishu interactive card docs) |
-## Return Value
-
-```json
-{
- "message_id": "om_xxx",
- "chat_id": "oc_xxx",
- "create_time": "1234567890"
-}
-```
+When using `--content`, you are responsible for making the JSON match the effective `msg_type`.
## @Mention Format
-The `<at>` syntax differs by message type. The shortcut only normalizes mentions for `text` and `post`; `interactive` card content is passed through verbatim, so cards must use the card-native syntax below.
-
-### `text`
-
-- `<at user_id="ou_xxx">name</at>` — the inner text is the mentioned user's display name and is optional (`<at user_id="ou_xxx"></at>` also works)
-- @all: `<at user_id="all"></at>`
-
-### `post`
+The `<at>` syntax differs by message type; the shortcut normalizes mentions for `text` and `post` only — `interactive` cards are passed through verbatim.
-- Inside a `text` or `md` element, the same inline form as `text` works: `<at user_id="ou_xxx">name</at>`
-- Or use a dedicated `at` element node: `{"tag":"at","user_id":"ou_xxx"}` (use `"all"` to mention everyone)
+- **`text`** / inside a `post` `text`/`md` element: `<at user_id="ou_xxx">name</at>` (inner name optional); @all: `<at user_id="all"></at>`. In `post` you may also use a node: `{"tag":"at","user_id":"ou_xxx"}` (`"all"` for everyone).
+- **`interactive` (card)** — card-native syntax inside a `lark_md`/`markdown` element: `<at id=ou_xxx></at>`, multiple `<at ids=ou_1,ou_2></at>`, by email `<at email=user@example.com></at>`.
-### `interactive` (card)
-
-Card content is **not** normalized — use the card-native `<at>` syntax inside a `lark_md` / `markdown` element:
-
-- single user by open_id: `<at id=ou_xxx></at>`
-- multiple users: `<at ids=ou_xxx1,ou_xxx2></at>`
-- by email: `<at email=user@example.com></at>`
-
-## Notes
+## Return Value
-- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
-- `--content` must be valid JSON
-- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
-- `--image`/`--file`/`--video`/`--audio` support existing keys, URLs, and cwd-relative local file paths; the shortcut uploads local paths and URLs first, then sends the message; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
-- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
-- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
-- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
-- When using `--video`, `--video-cover` is required as the video cover
-- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
-- Failures return an error code and message
-- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the message is sent as the authorized end user
-- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
-- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
-- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
+```json
+{"message_id": "om_xxx", "chat_id": "oc_xxx", "create_time": "1234567890"}
+```
--
2.50.1 (Apple Git-155)

View File

@@ -0,0 +1,135 @@
From cbd6e56ac07285fd973c53ff7382da0112b6cf5d Mon Sep 17 00:00:00 2001
From: "zhangheng.023" <zhangheng.023@bytedance.com>
Date: Tue, 23 Jun 2026 19:51:49 +0800
Subject: [PATCH] =?UTF-8?q?opt(round-003):=20references/lark-im-chat-creat?=
=?UTF-8?q?e.md=20=E2=80=94=20dedup=20Commands/Scenarios=20overlap=20+=20c?=
=?UTF-8?q?ompress=20--help-mirroring=20Common=20Errors=20into=20pointers,?=
=?UTF-8?q?=20keep=20232043=20two-step=20flow=20&=20all=20guardrails?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../lark-im/references/lark-im-chat-create.md | 78 +++++--------------
1 file changed, 18 insertions(+), 60 deletions(-)
diff --git a/skills/lark-im/references/lark-im-chat-create.md b/skills/lark-im/references/lark-im-chat-create.md
index 76716f76..7d65e5d3 100644
--- a/skills/lark-im/references/lark-im-chat-create.md
+++ b/skills/lark-im/references/lark-im-chat-create.md
@@ -12,43 +12,24 @@ This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `P
## Commands
```bash
-# Create a private group (default)
+# Private group (default)
lark-cli im +chat-create --name "My Group"
-# Create a public group (name is required and must be at least 2 characters)
+# Public group (--name required, min 2 chars)
lark-cli im +chat-create --name "Public Group" --type public
-# Create a topic chat
+# Topic chat (a 话题群; see note under Parameters)
lark-cli im +chat-create --name "Topic Group" --chat-mode topic
-# Specify the group owner
-lark-cli im +chat-create --name "My Group" --owner ou_xxx
+# Invite members and set owner (users: up to 50 ou_xxx; bots: up to 5 cli_xxx)
+lark-cli im +chat-create --name "My Group" --owner ou_xxx --users "ou_aaa,ou_bbb" --bots "cli_aaa"
-# Invite user members (comma-separated open_ids, up to 50)
-lark-cli im +chat-create --name "My Group" --users "ou_aaa,ou_bbb"
-
-# Invite bot members (comma-separated app IDs, up to 5)
-lark-cli im +chat-create --name "My Group" --bots "cli_aaa,cli_bbb"
-
-# Invite both users and bots
-lark-cli im +chat-create --name "My Group" --users "ou_aaa" --bots "cli_aaa"
-
-# Make the creating bot a group manager (bot identity only)
-lark-cli im +chat-create --name "My Group" --set-bot-manager --as bot
-
-# JSON output
-lark-cli im +chat-create --name "My Group" --format json
-
-# Create a group with bot identity
-lark-cli im +chat-create --name "My Group" --users "ou_aaa" --as bot
-
-# Create a group with user identity
-lark-cli im +chat-create --name "My Group" --users "ou_aaa,ou_bbb" --as user
-
-# Preview the request without creating anything
-lark-cli im +chat-create --name "My Group" --dry-run
+# Bot identity, making the creating bot a manager
+lark-cli im +chat-create --name "My Group" --users "ou_aaa" --as bot --set-bot-manager
```
+Run `lark-cli im +chat-create --help` for the full flag list, limits, and types. Single-flag variations (`--as user`, `--description`, `--format json`, `--dry-run` preview, etc.) follow the Parameters table below — `--dry-run` previews the request without creating anything.
+
## Parameters
| Parameter | Required | Limits | Description |
@@ -106,6 +87,13 @@ lark-cli im +chat-create --name "<group name>" --users "ou_aaa,ou_bbb" --as user
The authorized user is automatically the group creator and member.
+### Create a group, then send a welcome message
+
+```bash
+CHAT_ID=$(lark-cli im +chat-create --name "New Group" --format json | jq -r '.data.chat_id')
+lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Welcome, everyone!"
+```
+
## Output Fields
| Field | Description |
@@ -117,43 +105,13 @@ The authorized user is automatically the group creator and member.
| `external` | Whether the group is external |
| `share_link` | Group share link (omitted if retrieval fails) |
-## Usage Scenarios
-
-### Scenario 1: Create a group and specify the owner
-
-```bash
-lark-cli im +chat-create --name "Project Discussion Group" --owner ou_xxx
-```
-
-### Scenario 2: Create a group and invite users and a bot
-
-```bash
-lark-cli im +chat-create --name "Project Discussion Group" \
- --owner ou_xxx \
- --users "ou_aaa,ou_bbb" \
- --bots "cli_aaa"
-```
-
-### Scenario 3: Create a group and send a welcome message
-
-```bash
-CHAT_ID=$(lark-cli im +chat-create --name "New Group" --format json | jq -r '.data.chat_id')
-lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Welcome, everyone!"
-```
-
## Common Errors and Troubleshooting
+Format/limit validation (`--name`/`--description`/`--users`/`--bots`/`--owner` length, count, and `ou_xxx`/`cli_xxx` format) is enforced by the CLI and reported verbatim with the fix — see the Parameters table for limits. The two errors needing extra action:
+
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| Permission denied (99991672) | The app does not have `im:chat:create` (bot) or `im:chat:create_by_user` (user) permission enabled | Enable the required permission for the app in the Open Platform console |
-| `--name is required for public groups and must be at least 2 characters` | A public group was created without a name or with a name shorter than 2 characters | Provide a name with at least 2 characters |
-| `--name exceeds the maximum of 60 characters` | The group name is too long | Shorten the name to 60 characters or fewer |
-| `--description exceeds the maximum of 100 characters` | The group description is too long | Shorten the description to 100 characters or fewer |
-| `--users exceeds the maximum of 50` | Too many user members were provided | Split the operation into batches and add more members later |
-| `--bots exceeds the maximum of 5` | Too many bot members were provided | Invite at most 5 bots at once |
-| `invalid user id: expected open_id (ou_xxx)` | Invalid user ID format | Use the `ou_xxx` format for users |
-| `invalid bot id: expected app ID (cli_xxx)` | Invalid bot ID format | Use the `cli_xxx` format for bots |
-| `invalid --owner: expected open_id (ou_xxx)` | Invalid owner ID format | Use the `ou_xxx` format for the owner |
| `bot is invisible to user` (232043) | The bot and target users are mutually invisible | Follow the two-step flow in AI Usage Guidance above — do not pass other users in `--users` during creation |
## References
--
2.50.1 (Apple Git-155)

View File

@@ -113,7 +113,8 @@ type EnumOption struct {
}
// EnumOptions returns the field's allowed values paired with their descriptions
// — from enum, or from options when enum is absent — coerced to the canonical
// — from enum (with descriptions backfilled from options when the field carries
// both forms), or from options when enum is absent — coerced to the canonical
// type and ordered: numeric and boolean values are sorted; string values keep
// source order (which can encode priority). Uncoercible literals are dropped.
// Returns nil when the field declares no enum constraint.
@@ -122,9 +123,14 @@ func (f Field) EnumOptions() []EnumOption {
var out []EnumOption
switch {
case len(f.Enum) > 0:
// key by raw literal so enum "1" and option 1 align across JSON types
desc := make(map[string]string, len(f.Options))
for _, o := range f.Options {
desc[fmt.Sprintf("%v", o.Value)] = o.Description
}
for _, e := range f.Enum {
if v, ok := coerceLiteral(ct, e); ok {
out = append(out, EnumOption{Value: v})
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
}
}
case len(f.Options) > 0:

View File

@@ -80,6 +80,39 @@ func TestField_EnumOptions(t *testing.T) {
}
}
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
// enum is the value set; descriptions backfilled from options, empty where absent
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
{Value: "6", Description: "subject"},
}}
want := []EnumOption{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
{Value: "3", Description: ""},
{Value: "4", Description: ""},
{Value: "6", Description: "subject"},
}
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
}
// enum values stored as strings match option values stored as numbers
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
{Value: 1, Description: "one"},
{Value: 2, Description: "two"},
}}
wantI := []EnumOption{
{Value: int64(1), Description: "one"},
{Value: int64(2), Description: "two"},
{Value: int64(10), Description: ""},
}
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
}
}
func TestField_Enum_NumberAndBoolean(t *testing.T) {
// number: string-stored floats coerced to float64 and numerically sorted
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {

View File

@@ -472,6 +472,18 @@ func TestConvert_EnumDescriptions(t *testing.T) {
if bare.EnumDescriptions != nil {
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
}
// enum + options both present -> enumDescriptions backfilled, aligned, "" where absent
both := Convert(meta.Field{Type: "string", Enum: []any{"1", "2", "3"}, Options: []meta.Option{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
}})
if !reflect.DeepEqual(both.Enum, []interface{}{"1", "2", "3"}) {
t.Errorf("both Enum = %v", both.Enum)
}
if !reflect.DeepEqual(both.EnumDescriptions, []string{"from", "to", ""}) {
t.Errorf("both EnumDescriptions = %v, want [from to \"\"] aligned with enum", both.EnumDescriptions)
}
}
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.55",
"version": "1.0.56",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -89,7 +89,7 @@ def main():
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
with open(OUT_PATH, "w") as fp:
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")

View File

@@ -17,6 +17,7 @@ import (
"text/tabwriter"
"time"
"github.com/google/uuid"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
@@ -32,6 +33,7 @@ import (
)
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
// gitCredentialIssueHint is the actionable next-step attached to a failed
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
@@ -302,7 +304,12 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
HttpMethod: http.MethodGet,
ApiPath: issuePath(appID),
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
ctx = contextWithGitCredentialHelperShortcut(ctx)
var opts []larkcore.RequestOptionFunc
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
opts = append(opts, optFn)
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
Brand: string(cfg.Brand),
AppID: cfg.AppID,
@@ -314,6 +321,13 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
return issuedFromData(appID, data)
}
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
return ctx
}
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
}
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
if f == nil || f.IOStreams == nil {
return nil

View File

@@ -825,7 +825,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) {
func TestFactoryIssuerBranches(t *testing.T) {
factory, _, reg := newAppsExecuteFactory(t)
expiresAt := time.Now().Add(24 * time.Hour).Unix()
reg.Register(&httpmock.Stub{
issueStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
@@ -836,7 +836,8 @@ func TestFactoryIssuerBranches(t *testing.T) {
"StatusCode": 0,
},
},
})
}
reg.Register(issueStub)
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
if err != nil {
t.Fatalf("factory issuer returned error: %v", err)
@@ -844,6 +845,12 @@ func TestFactoryIssuerBranches(t *testing.T) {
if issued.PAT != "pat-token" {
t.Fatalf("PAT = %q", issued.PAT)
}
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
}
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
}
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
@@ -880,6 +887,20 @@ func TestFactoryIssuerBranches(t *testing.T) {
}
}
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
got := contextWithGitCredentialHelperShortcut(ctx)
name, ok := cmdutil.ShortcutNameFromContext(got)
if !ok || name != "apps:+git-credential-init" {
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
}
executionID, ok := cmdutil.ExecutionIdFromContext(got)
if !ok || executionID != "exec-existing" {
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
}
}
func TestGitCredentialHelpersAndParsers(t *testing.T) {
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))

View File

@@ -223,6 +223,12 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
return v
}
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
func (ctx *RuntimeContext) IntArray(name string) []int {
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name)
@@ -1176,6 +1182,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
cmd.Flags().Float64(fl.Name, d, desc)
case "int_array":
cmd.Flags().IntSlice(fl.Name, nil, desc)
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice":

View File

@@ -4,9 +4,12 @@
package common
import (
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -56,3 +59,29 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
t.Fatalf("expected no error for empty args, got: %v", err)
}
}
func TestShortcutFlagIntArray(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
var got []int
shortcut := Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "capture screenshots",
Flags: []Flag{
{Name: "slide-number", Type: "int_array"},
},
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
got = runtime.IntArray("slide-number")
return nil
},
}
shortcut.Mount(parent, f)
parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"})
if err := parent.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
t.Fatalf("slide-number = %#v, want %#v", got, want)
}
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime

View File

@@ -85,6 +85,7 @@ type searchUserAPIData struct {
Items []searchUserAPIItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
Notice string `json:"notice"`
}
type searchUserAPIItem struct {
@@ -126,6 +127,7 @@ type searchUser struct {
type searchUserResponse struct {
Users []searchUser `json:"users"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
var ContactSearchUser = common.Shortcut{
@@ -189,6 +191,7 @@ var ContactSearchUser = common.Shortcut{
Execute: executeSearchUser,
}
// executeSearchUser dispatches contact search to single-query or fanout mode.
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeSearchUserFanout(ctx, runtime)
@@ -196,6 +199,7 @@ func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) erro
return executeSearchUserSingle(ctx, runtime)
}
// executeSearchUserSingle performs one contact search and preserves server notices.
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildSearchUserBody(runtime)
if err != nil {
@@ -222,7 +226,7 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
}
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
out := searchUserResponse{Users: users, HasMore: hasMore}
out := searchUserResponse{Users: users, HasMore: hasMore, Notice: respData.Notice}
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
if len(users) == 0 {

View File

@@ -45,22 +45,17 @@ type fanoutResult struct {
Query string
Users []searchUser
HasMore bool
Notice string
ErrMsg string // empty = success
Err error // original failure, kept for typed all-failed propagation
}
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
// because that summary lives on stderr and never corrupts the csv stream on
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
// for its refine hint, so adding csv here doesn't regress that path.
// isFanoutSummaryFormat gates the per-fanout stderr summary line.
func isFanoutSummaryFormat(format string) bool {
return format == "pretty" || format == "table" || format == "csv"
}
// runOneQuery converts every failure mode (transport, HTTP status, parse,
// API code) into an ErrMsg string instead of returning a Go error. The
// fanout dispatcher (Task 6) relies on this so a single failed query never
// short-circuits the remaining workers.
// runOneQuery converts one fanout request into either users or an error summary.
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *searchUserAPIFilter) fanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a
@@ -94,9 +89,10 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
}
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore, Notice: respData.Notice}
}
// fanoutErrorResult records a failed fanout query without stopping other workers.
func fanoutErrorResult(index int, query string, err error) fanoutResult {
if err == nil {
return fanoutResult{Index: index, Query: query}
@@ -113,17 +109,16 @@ type querySummary struct {
Query string `json:"query"`
Error string `json:"error,omitempty"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
type fanoutResponse struct {
Users []fanoutUser `json:"users"`
Queries []querySummary `json:"queries"`
Notice string `json:"notice,omitempty"`
}
// buildFanoutResponse walks results by Index (input order), flattens users[]
// with matched_query, lists every input in queries[] (including successes),
// and returns an error only when every query failed. The error wraps the
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
// buildFanoutResponse flattens ordered fanout results and fails only when all queries fail.
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
indexed := make([]fanoutResult, len(queries))
for _, r := range results {
@@ -142,6 +137,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
Notice: r.Notice,
})
if r.ErrMsg != "" {
failed++
@@ -152,6 +148,9 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
}
continue
}
if out.Notice == "" {
out.Notice = r.Notice
}
for _, u := range r.Users {
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
}

View File

@@ -562,6 +562,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
return parent.Execute()
}
// searchUserStub returns a representative user search response with a notice.
func searchUserStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
@@ -569,6 +570,7 @@ func searchUserStub() *httpmock.Stub {
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"items": []interface{}{
map[string]interface{}{
"id": "ou_a",
@@ -590,6 +592,7 @@ func searchUserStub() *httpmock.Stub {
}
}
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
@@ -614,6 +617,7 @@ func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
}
}
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
@@ -631,6 +635,9 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
if !ok {
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
}
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("data.notice = %v", data["notice"])
}
users, _ := data["users"].([]interface{})
if len(users) != 1 {
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
@@ -1358,6 +1365,7 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
}
}
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := &httpmock.Stub{
@@ -1399,6 +1407,7 @@ func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
}
}
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
@@ -1406,6 +1415,7 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
@@ -1432,10 +1442,17 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
if len(users) != 1 {
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
}
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("data.notice = %v", data["notice"])
}
queries := data["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries: expected 2, got %d", len(queries))
}
q0 := queries[0].(map[string]interface{})
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("queries[0].notice = %v", q0["notice"])
}
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
t.Errorf("queries[1].error: got %q", q1["error"])

View File

@@ -74,6 +74,9 @@ var DocsSearch = common.Shortcut{
"page_token": data["page_token"],
"results": normalizedItems,
}
if notice, _ := data["notice"].(string); notice != "" {
resultData["notice"] = notice
}
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
if len(normalizedItems) == 0 {

View File

@@ -7,8 +7,48 @@ import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// TestDocsSearchExecutePassesThroughNotice verifies docs +search preserves notices.
func TestDocsSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-search-notice"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"res_units": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
},
})
if err := mountAndRunDocs(t, DocsSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("DocsSearch.Execute() error = %v", err)
}
reg.Verify(t)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
}
data, _ := env["data"].(map[string]interface{})
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestAddIsoTimeFieldsSupportsJSONNumber verifies JSON numbers get ISO fields.
func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) {
t.Parallel()

View File

@@ -121,7 +121,7 @@ const (
var DriveAddComment = common.Shortcut{
Service: "drive",
Command: "+add-comment",
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
Description: "Add a comment to doc/docx/file/sheet/slides/base(bitable); file targets support selected extensions and full comments only",
Risk: "write",
Scopes: []string{
"drive:drive.metadata:readonly",
@@ -131,12 +131,12 @@ var DriveAddComment = common.Shortcut{
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell>; for slides: <slide-block-type>!<xml-id>; for base(bitable): <table-id>!<record-id>!<view-id>"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
@@ -148,6 +148,17 @@ var DriveAddComment = common.Shortcut{
return err
}
if docRef.Kind == "base" {
if runtime.Bool("full-comment") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--full-comment")
}
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--selection-with-ellipsis")
}
_, err := parseBaseCommentAnchor(runtime)
return err
}
// Sheet comment validation.
if docRef.Kind == "sheet" {
blockID := strings.TrimSpace(runtime.Str("block-id"))
@@ -188,7 +199,7 @@ var DriveAddComment = common.Shortcut{
return validateFileCommentMode(mode, "")
}
if mode == commentModeLocal && docRef.Kind == "doc" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
}
return nil
@@ -215,6 +226,23 @@ var DriveAddComment = common.Shortcut{
resolvedToken = target.FileToken
}
if resolvedKind == "base" {
anchor, err := parseBaseCommentAnchor(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
commentBody := buildBaseCommentCreateV2Request(replyElements, anchor)
desc := "1-step request: create base(bitable) record-local comment"
if isWiki {
desc = "2-step orchestration: resolve wiki -> create base(bitable) record-local comment"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/files/:file_token/new_comments").
Body(commentBody).
Set("file_token", resolvedToken)
}
// Sheet comment dry-run.
if resolvedKind == "sheet" {
anchor, _ := parseSheetCellRef(blockID)
@@ -352,6 +380,14 @@ var DriveAddComment = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Sheet comment: direct URL or token fast path.
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
if docRef.Kind == "base" {
return executeBaseComment(runtime, resolvedCommentTarget{
DocID: docRef.Token,
FileToken: docRef.Token,
FileType: "base",
ResolvedBy: "base",
})
}
if docRef.Kind == "sheet" {
return executeSheetComment(runtime, docRef)
}
@@ -375,6 +411,9 @@ var DriveAddComment = common.Shortcut{
if target.FileType == "slides" {
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
}
if target.FileType == "base" {
return executeBaseComment(runtime, target)
}
if target.FileType == "file" {
return executeFileComment(runtime, target)
}
@@ -482,6 +521,12 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
if token, ok := extractURLToken(raw, "/sheets/"); ok {
return commentDocRef{Kind: "sheet", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/base/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/bitable/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/file/"); ok {
return commentDocRef{Kind: "file", Token: token}, nil
}
@@ -495,7 +540,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
@@ -504,7 +549,10 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
// Bare token: --type is required.
docType = strings.TrimSpace(docType)
if docType == "" {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides, bitable, base; use bitable as the wire value, base is accepted as a compatibility alias)").WithParam("--type")
}
if docType == "bitable" || docType == "base" {
return commentDocRef{Kind: "base", Token: raw}, nil
}
return commentDocRef{Kind: docType, Token: raw}, nil
}
@@ -515,11 +563,11 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
return resolvedCommentTarget{}, err
}
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" || docRef.Kind == "base" {
if mode == commentModeLocal {
switch docRef.Kind {
case "doc":
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
case "file":
if err := validateFileCommentMode(mode, ""); err != nil {
return resolvedCommentTarget{}, err
@@ -557,6 +605,22 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
}
if objType == "bitable" || objType == "base" {
if runtime.Bool("full-comment") {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--full-comment")
}
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--selection-with-ellipsis")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken))
return resolvedCommentTarget{
DocID: objToken,
FileToken: objToken,
FileType: "base",
ResolvedBy: "wiki",
WikiToken: docRef.Token,
}, nil
}
if objType == "sheet" {
// Sheet comments are handled via the sheet fast path in Execute.
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -592,10 +656,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}, nil
}
if mode == commentModeLocal && objType != "docx" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, slides, and base(bitable); for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>, for base use --block-id <table-id>!<record-id>!<view-id>", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base(bitable)", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -787,6 +851,12 @@ type sheetAnchor struct {
Row int
}
type baseAnchor struct {
BlockID string
BaseRecordID string
BaseViewID string
}
func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
body := map[string]interface{}{
"file_type": fileType,
@@ -813,6 +883,18 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
return body
}
func buildBaseCommentCreateV2Request(replyElements []map[string]interface{}, anchor baseAnchor) map[string]interface{} {
return map[string]interface{}{
"file_type": "bitable",
"reply_elements": replyElements,
"anchor": map[string]interface{}{
"block_id": anchor.BlockID,
"base_record_id": anchor.BaseRecordID,
"base_view_id": anchor.BaseViewID,
},
}
}
func anchorBlockIDForDryRun(blockID string) string {
if strings.TrimSpace(blockID) != "" {
return strings.TrimSpace(blockID)
@@ -820,6 +902,26 @@ func anchorBlockIDForDryRun(blockID string) string {
return "<anchor_block_id>"
}
func parseBaseCommentAnchor(runtime *common.RuntimeContext) (baseAnchor, error) {
blockID := strings.TrimSpace(runtime.Str("block-id"))
if blockID == "" {
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for base(bitable) record-local comments (format: <table-id>!<record-id>!<view-id>, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R)").WithParam("--block-id")
}
return parseBaseBlockRef(blockID)
}
func parseBaseBlockRef(blockID string) (baseAnchor, error) {
parts := strings.Split(strings.TrimSpace(blockID), "!")
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "base(bitable) record-local comments require --block-id in <table-id>!<record-id>!<view-id> format, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R").WithParam("--block-id")
}
return baseAnchor{
BlockID: strings.TrimSpace(parts[0]),
BaseRecordID: strings.TrimSpace(parts[1]),
BaseViewID: strings.TrimSpace(parts[2]),
}, nil
}
func parseSlidesBlockRef(blockID string) (string, string, error) {
blockID = strings.TrimSpace(blockID)
if blockID == "" {
@@ -1030,6 +1132,53 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
return nil
}
func executeBaseComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return err
}
anchor, err := parseBaseCommentAnchor(runtime)
if err != nil {
return err
}
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
requestBody := buildBaseCommentCreateV2Request(replyElements, anchor)
fmt.Fprintf(runtime.IO().ErrOut, "Creating base(bitable) record-local comment in %s (table=%s, record=%s, view=%s)\n",
common.MaskToken(target.FileToken), anchor.BlockID, anchor.BaseRecordID, anchor.BaseViewID)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": target.FileToken,
"file_type": "bitable",
"resolved_by": target.ResolvedBy,
"comment_mode": "base_record",
"base_block_id": anchor.BlockID,
"base_record_id": anchor.BaseRecordID,
"base_view_id": anchor.BaseViewID,
}
if commentID := data["comment_id"]; commentID != nil {
out["comment_id"] = commentID
}
if replyID := data["reply_id"]; replyID != nil {
out["reply_id"] = replyID
}
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
out["created_at"] = createdAt
}
if target.WikiToken != "" {
out["wiki_token"] = target.WikiToken
}
runtime.Out(out, nil)
return nil
}
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {

View File

@@ -133,6 +133,20 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "file",
wantToken: "fileToken",
},
{
name: "raw token with type bitable",
input: "baseToken",
docType: "bitable",
wantKind: "base",
wantToken: "baseToken",
},
{
name: "raw token with type base alias",
input: "baseToken",
docType: "base",
wantKind: "base",
wantToken: "baseToken",
},
{
name: "raw token without type",
input: "xxxxxx",
@@ -156,6 +170,18 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "file",
wantToken: "boxcn123",
},
{
name: "base url",
input: "https://example.larksuite.com/base/baseToken123?table=tbl1",
wantKind: "base",
wantToken: "baseToken123",
},
{
name: "bitable url",
input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1",
wantKind: "base",
wantToken: "baseToken456",
},
{
name: "unsupported url",
input: "https://example.com/not-a-doc",
@@ -726,6 +752,35 @@ func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
}
}
func TestBuildBaseCommentCreateV2Request(t *testing.T) {
t.Parallel()
replyElements := []map[string]interface{}{
{"type": "text", "text": "base comment"},
}
got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{
BlockID: "tbl9mp6fj9kDKHQV",
BaseRecordID: "recBIBgGmb",
BaseViewID: "vewc46MG1R",
})
if got["file_type"] != "bitable" {
t.Fatalf("expected file_type bitable, got %#v", got["file_type"])
}
anchor, ok := got["anchor"].(map[string]interface{})
if !ok {
t.Fatalf("expected anchor map, got %#v", got["anchor"])
}
if anchor["block_id"] != "tbl9mp6fj9kDKHQV" {
t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"])
}
if anchor["base_record_id"] != "recBIBgGmb" {
t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"])
}
if anchor["base_view_id"] != "vewc46MG1R" {
t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"])
}
}
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
func TestParseSheetCellRef(t *testing.T) {
@@ -985,6 +1040,78 @@ func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
}
}
func TestBaseCommentValidateMissingBlockID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
t.Fatalf("expected block-id required error, got: %v", err)
}
}
func TestBaseCommentValidateMalformedBlockID(t *testing.T) {
cases := []string{
"tbl9mp6fj9kDKHQV",
"tbl9mp6fj9kDKHQV!recBIBgGmb",
"tbl9mp6fj9kDKHQV!!vewc46MG1R",
}
for _, blockID := range cases {
t.Run(blockID, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", blockID,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "<table-id>!<record-id>!<view-id>") {
t.Fatalf("expected block-id format error, got: %v", err)
}
})
}
}
func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string
}{
{
name: "full comment",
args: []string{"--full-comment"},
wantErr: "--full-comment is not applicable for base(bitable) comments",
},
{
name: "selection",
args: []string{"--selection-with-ellipsis", "some text"},
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
args := []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}
args = append(args, tc.args...)
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
}
})
}
}
// ── Slides comment execute tests ────────────────────────────────────────────
func TestSlidesCommentExecuteSuccess(t *testing.T) {
@@ -1195,6 +1322,87 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
}
}
func TestBaseCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"comment_id": "baseComment123",
"reply_id": "baseReply123",
"created_at": 1700000000,
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"请看这条记录"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody))
}
if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" {
t.Fatalf("request file_type = %q, want bitable", got)
}
anchor := mustMapValue(t, requestBody["anchor"], "request.anchor")
if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got)
}
if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" {
t.Fatalf("request base_record_id = %q, want recBIBgGmb", got)
}
if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" {
t.Fatalf("request base_view_id = %q, want vewc46MG1R", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" {
t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" {
t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String())
}
}
func TestBaseCommentExecuteBareToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "baseBareComment"},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "baseBareToken",
"--type", "bitable",
"--content", `[{"type":"text","text":"ok"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "baseBareComment") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
}
func TestFileCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1433,6 +1641,40 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
}
}
func TestDryRunBaseDirectURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "record-local comment") {
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" {
t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" {
t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" {
t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String())
}
}
func TestDryRunWikiResolvesToSlides(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1636,25 +1878,92 @@ func TestResolveWikiToDocxFullComment(t *testing.T) {
}
}
func TestResolveWikiToUnsupportedType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
},
func TestResolveWikiToBaseComment(t *testing.T) {
for _, objType := range []string{"bitable", "base"} {
t.Run(objType, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "wikiBaseComment") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" {
t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String())
}
})
}
}
func TestResolveWikiToBaseRejectsIncompatibleFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string
}{
{
name: "full comment",
args: []string{"--full-comment"},
wantErr: "--full-comment is not applicable for base(bitable) comments",
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
t.Fatalf("expected unsupported type error, got: %v", err)
{
name: "selection",
args: []string{"--selection-with-ellipsis", "some text"},
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
},
},
})
args := []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}
args = append(args, tc.args...)
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
}
})
}
}
@@ -1735,7 +2044,7 @@ func TestDocOldFormatLocalCommentRejected(t *testing.T) {
"--block-id", "blk_123",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, and slides") {
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") {
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
}
}

View File

@@ -149,6 +149,9 @@ var DriveSearch = common.Shortcut{
"page_token": data["page_token"],
"results": normalizedItems,
}
if notice, _ := data["notice"].(string); notice != "" {
resultData["notice"] = notice
}
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
renderDriveSearchTable(w, data, normalizedItems)

View File

@@ -14,12 +14,49 @@ import (
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestClampOpenedTimeWindow covers the 3-month / 1-year boundary logic that
// narrows --opened-since / --opened-until and generates the multi-slice notice.
// TestDriveSearchExecutePassesThroughNotice verifies drive +search preserves notices.
func TestDriveSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"res_units": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
},
})
if err := mountAndRunDrive(t, DriveSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("DriveSearch.Execute() error = %v", err)
}
reg.Verify(t)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
}
data, _ := env["data"].(map[string]interface{})
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestClampOpenedTimeWindow covers opened-time clamping and slice notices.
func TestClampOpenedTimeWindow(t *testing.T) {
t.Parallel()

View File

@@ -26,9 +26,7 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
return string(b)
}
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
// command whose flags are populated from the provided string and bool maps,
// for unit-testing shortcut bodies, validators, and dry-run shapes.
// newTestRuntimeContext builds a RuntimeContext with string and bool test flags.
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -59,9 +57,38 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext is the messages-search variant of
// newTestRuntimeContext: registers the search-specific --page-size flag
// before applying caller-provided values.
// newChatSearchTestRuntimeContext builds a chat-search RuntimeContext with typed flags.
func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext builds a messages-search RuntimeContext.
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -231,6 +258,7 @@ func TestIsMediaKey(t *testing.T) {
}
}
// TestShortcutValidateBranches covers direct shortcut validation branches.
func TestShortcutValidateBranches(t *testing.T) {
t.Run("ImChatCreate valid", func(t *testing.T) {
@@ -297,7 +325,7 @@ func TestShortcutValidateBranches(t *testing.T) {
})
t.Run("ImChatSearch invalid page size", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": "ok",
"page-size": "0",
}, nil)
@@ -307,12 +335,13 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImChatSearch query too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": strings.Repeat("q", 65),
t.Run("ImChatSearch allows long query for server-side notice", func(t *testing.T) {
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": strings.Repeat("q", 81),
"page-size": "20",
}, nil)
err := ImChatSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") {
if err != nil {
t.Fatalf("ImChatSearch.Validate() error = %v", err)
}
})
@@ -607,6 +636,7 @@ func TestShortcutValidateBranches(t *testing.T) {
})
}
// TestMessagesSearchPaginationConfig verifies page-all and page-limit behavior.
func TestMessagesSearchPaginationConfig(t *testing.T) {
t.Run("default single page", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, nil, nil)
@@ -650,8 +680,7 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
})
}
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
// produces the expected API path, query parameters, and request body.
// TestShortcutDryRunShapes verifies shortcut dry-run API paths and payloads.
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
@@ -674,19 +703,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
})
t.Run("ImChatSearch dry run includes built params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"page-size": "50",
"page-token": "next_page",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":50`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
t.Fatalf("ImChatSearch.DryRun() = %s", got)
}
})
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
}, map[string]bool{
"exclude-muted": true,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,993 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"strings"
"testing"
)
func TestConvertInteractiveEventContent(t *testing.T) {
// invalid JSON → fallback
if got := ConvertInteractiveEventContent("not-json", nil); got != "[interactive card]" {
t.Fatalf("invalid JSON = %q, want [interactive card]", got)
}
// missing user_dsl → fallback
if got := ConvertInteractiveEventContent(`{"other":"field"}`, nil); got != "[interactive card]" {
t.Fatalf("missing user_dsl = %q, want [interactive card]", got)
}
// empty user_dsl → fallback
if got := ConvertInteractiveEventContent(`{"user_dsl":""}`, nil); got != "[interactive card]" {
t.Fatalf("empty user_dsl = %q, want [interactive card]", got)
}
// user_dsl that is not a string (wrong type) → fallback
if got := ConvertInteractiveEventContent(`{"user_dsl":123}`, nil); got != "[interactive card]" {
t.Fatalf("non-string user_dsl = %q, want [interactive card]", got)
}
// valid user-2 card → <card> output
userDsl := `{"schema":"2.0","header":{"title":{"tag":"plain_text","content":"Hello"}},"body":{"elements":[{"tag":"markdown","content":"world"}]}}`
rawContent := `{"user_dsl":"` + strings.ReplaceAll(userDsl, `"`, `\"`) + `"}`
got := ConvertInteractiveEventContent(rawContent, nil)
if !strings.HasPrefix(got, `<card title="Hello">`) {
t.Fatalf("valid card = %q, want prefix <card title=\"Hello\">", got)
}
if !strings.Contains(got, "world") {
t.Fatalf("valid card = %q, want to contain 'world'", got)
}
}
func makeMentionCard(mdContent string) string {
obj := map[string]interface{}{
"schema": "2.0",
"header": map[string]interface{}{
"title": map[string]interface{}{"tag": "plain_text", "content": "T"},
},
"body": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{"tag": "markdown", "content": mdContent},
},
},
}
dslBytes, _ := json.Marshal(obj)
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
return string(raw)
}
func TestConvertInteractiveEventContentMentions(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"name": "test-user",
"id": map[string]interface{}{"open_id": "fake-uid-001"},
},
}
// quoted attrs: mention_key="key"
got := ConvertInteractiveEventContent(makeMentionCard(`hi <at mention_key="@_user_1">n</at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("quoted mention_key not resolved, got: %s", got)
}
// unquoted attrs (real Lark format): <at id=ou_xxx mention_key=@_user_1></at>
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_user_1></at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("unquoted mention_key not resolved, got: %s", got)
}
// mentions_key variant (unquoted)
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at mentions_key=@_user_1></at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("unquoted mentions_key not resolved, got: %s", got)
}
// degradation 1: no mention_key/mentions_key attr → fall back to @id (unquoted)
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001></at> done`), mentions)
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("no mention_key unquoted: expected @id fallback, got: %s", got)
}
// degradation 2: mention_key not found in mentions → fall back to @id
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_unknown></at> done`), mentions)
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("key not in mentions: expected @id fallback, got: %s", got)
}
// multi-mention: ids=id1,id2,id3 mentions_key=k1,,k3
// k1 hits → @name(id1), k2 empty → @id2 fallback, k3 not found → @id3 fallback
got = ConvertInteractiveEventContent(
makeMentionCard(`<at ids=fake-uid-001,fake-uid-002,fake-uid-003 mentions_key=@_user_1,,@_unknown></at>`),
mentions,
)
want := "@test-user(fake-uid-001)@fake-uid-002@fake-uid-003"
if !strings.Contains(got, want) {
t.Fatalf("multi-mention unquoted: want %q in output, got: %s", want, got)
}
}
func TestUserDslConverterSchema(t *testing.T) {
c := &userDslConverter{}
// user-2.ts: schema field present, header at root, body.elements
schema2 := cardObj{
"schema": "2.0",
"header": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Schema2 Title"},
"subtitle": cardObj{"tag": "plain_text", "content": "Sub"},
},
"body": cardObj{
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "body text"},
},
},
}
got := c.convert(schema2)
if got != "<card title=\"Schema2 Title\" subtitle=\"Sub\">\nbody text\n</card>" {
t.Fatalf("schema2 = %q", got)
}
// user-1.ts: no schema field, i18n_header.zh_cn, elements at root
schema1 := cardObj{
"i18n_header": cardObj{
"zh_cn": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Schema1 Title"},
},
},
"elements": []interface{}{
cardObj{"tag": "hr"},
},
}
got = c.convert(schema1)
if got != "<card title=\"Schema1 Title\">\n---\n</card>" {
t.Fatalf("schema1 = %q", got)
}
// user-1.ts: no schema, direct header (real Lark event format)
schema1Direct := cardObj{
"header": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Direct Header Title"},
"subtitle": cardObj{"tag": "plain_text", "content": "Direct Sub"},
},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "direct body"},
},
}
got = c.convert(schema1Direct)
if got != "<card title=\"Direct Header Title\" subtitle=\"Direct Sub\">\ndirect body\n</card>" {
t.Fatalf("schema1 direct header = %q", got)
}
// no header, no elements → fallback
got = c.convert(cardObj{})
if got != "[interactive card]" {
t.Fatalf("empty card = %q, want [interactive card]", got)
}
// card with title only → valid (not "[interactive card]")
titleOnly := cardObj{
"schema": "2.0",
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "TitleOnly"}},
"body": cardObj{"elements": []interface{}{}},
}
got = c.convert(titleOnly)
if !strings.Contains(got, "TitleOnly") {
t.Fatalf("title-only card = %q, want to contain TitleOnly", got)
}
}
func TestUserDslConverterDispatch(t *testing.T) {
c := &userDslConverter{}
tests := []struct {
name string
elem cardObj
want string
contains string
}{
{
name: "plain_text",
elem: cardObj{"tag": "plain_text", "content": "hello"},
want: "hello",
},
{
name: "markdown",
elem: cardObj{"tag": "markdown", "content": "**bold**"},
want: "**bold**",
},
{
name: "hr",
elem: cardObj{"tag": "hr"},
want: "---",
},
{
name: "br",
elem: cardObj{"tag": "br"},
want: "\n",
},
{
name: "img with img_key",
elem: cardObj{
"tag": "img",
"img_key": "img_v3_abc",
"alt": cardObj{"tag": "plain_text", "content": "Banner"},
},
want: "🖼️ Banner(img_key:img_v3_abc)",
},
{
name: "img_combination",
elem: cardObj{
"tag": "img_combination",
"img_list": []interface{}{
cardObj{"img_key": "k1"},
cardObj{"img_key": "k2"},
},
},
want: "🖼️ 2 image(s)(keys:k1,k2)",
},
{
name: "button with behaviors default_url",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Open"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com"},
},
},
want: "[Open](https://example.com)",
},
{
name: "button disabled",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Nope"},
"disabled": true,
},
want: "[Nope ✗]",
},
{
name: "button no url",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Submit"},
},
want: "[Submit]",
},
{
name: "action wrapper (user-1 style)",
elem: cardObj{
"tag": "action",
"actions": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "A"}},
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "B"}},
},
},
want: "[A] [B]",
},
{
name: "overflow",
elem: cardObj{
"tag": "overflow",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Edit"}},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Delete"}},
},
},
want: "⋮ Edit, Delete",
},
{
name: "select_static no selection",
elem: cardObj{
"tag": "select_static",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
},
},
want: "{Option1 / Option2 ▼}",
},
{
name: "select_static with initial_option",
elem: cardObj{
"tag": "select_static",
"initial_option": "2",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
},
},
want: "{Option1 / ✓Option2}",
},
{
name: "multi_select_static with selected_values",
elem: cardObj{
"tag": "multi_select_static",
"selected_values": []interface{}{"A"},
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptA"}, "value": "A"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptB"}, "value": "B"},
},
},
want: "{✓OptA / OptB}(multi)",
},
{
name: "select_person no options no selection shows placeholder",
elem: cardObj{
"tag": "select_person",
"placeholder": cardObj{"tag": "plain_text", "content": "请选择"},
},
want: "{请选择 ▼}",
},
{
name: "select_person with initial_option synthesizes from ID",
elem: cardObj{
"tag": "select_person",
"initial_option": "fake-open-id-001",
},
want: "{✓fake-open-id-001}",
},
{
name: "multi_select_person with selected_values shows IDs and multi",
elem: cardObj{
"tag": "multi_select_person",
"selected_values": []interface{}{"fake-open-id-001", "fake-open-id-002"},
},
want: "{✓fake-open-id-001 / ✓fake-open-id-002}(multi)",
},
{
name: "multi_select_person no selection shows placeholder",
elem: cardObj{
"tag": "multi_select_person",
"placeholder": cardObj{"tag": "plain_text", "content": "添加人员"},
},
want: "{添加人员 ▼}(multi)",
},
{
name: "input with default_value",
elem: cardObj{
"tag": "input",
"label": cardObj{"tag": "plain_text", "content": "Reason"},
"default_value": "prefilled",
},
want: "Reason: prefilled___",
},
{
name: "input with placeholder",
elem: cardObj{
"tag": "input",
"placeholder": cardObj{"tag": "plain_text", "content": "Type here"},
},
want: "Type here_____",
},
{
name: "date_picker with initial_date",
elem: cardObj{
"tag": "date_picker",
"initial_date": "2026-01-01",
},
want: "📅 2026-01-01",
},
{
name: "date_picker placeholder",
elem: cardObj{
"tag": "date_picker",
"placeholder": cardObj{"tag": "plain_text", "content": "Pick date"},
},
want: "📅 Pick date",
},
{
name: "picker_time with initial_time",
elem: cardObj{
"tag": "picker_time",
"initial_time": "14:30",
},
want: "🕐 14:30",
},
{
name: "checker unchecked",
elem: cardObj{
"tag": "checker",
"text": cardObj{"tag": "plain_text", "content": "Task A"},
},
want: "[ ] Task A",
},
{
name: "checker checked",
elem: cardObj{
"tag": "checker",
"checked": true,
"text": cardObj{"tag": "plain_text", "content": "Task B"},
},
want: "[x] Task B",
},
{
name: "chart with chart_spec",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": "month",
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "value": float64(10)},
cardObj{"month": "Feb", "value": float64(20)},
}},
},
},
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
},
{
name: "chart with compound xField array",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": []interface{}{"month", "category"},
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "category": "A", "value": float64(10)},
cardObj{"month": "Feb", "category": "B", "value": float64(20)},
}},
},
},
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
},
{
name: "chart no custom title uses type name",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"type": "pie",
"categoryField": "label",
"valueField": "val",
"data": cardObj{"values": []interface{}{
cardObj{"label": "A", "val": float64(1)},
}},
},
},
want: "📊 Pie chart\nSummary: A:1",
},
{
name: "chart vchart array data format",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"type": "bar",
"xField": "x",
"yField": "y",
"data": []interface{}{
cardObj{"id": "s1", "values": []interface{}{
cardObj{"x": "Jan", "y": float64(5)},
}},
cardObj{"id": "s2", "values": []interface{}{
cardObj{"x": "Feb", "y": float64(8)},
}},
},
},
},
want: "📊 Bar chart\nSummary: Jan:5, Feb:8",
},
{
name: "text_tag",
elem: cardObj{
"tag": "text_tag",
"text": cardObj{"tag": "plain_text", "content": "新功能"},
},
want: "「新功能」",
},
{
name: "avatar with user_id",
elem: cardObj{"tag": "avatar", "user_id": "fake-open-id-001"},
want: "👤(id:fake-open-id-001)",
},
{
name: "avatar no user_id",
elem: cardObj{"tag": "avatar"},
want: "👤",
},
{
name: "select_img no selection",
elem: cardObj{
"tag": "select_img",
"options": []interface{}{
cardObj{"value": "v1", "img_key": "img_k1"},
cardObj{"value": "v2", "img_key": "img_k2"},
},
},
want: "{🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
},
{
name: "select_img with selected",
elem: cardObj{
"tag": "select_img",
"selected_values": []interface{}{"v1"},
"options": []interface{}{
cardObj{"value": "v1", "img_key": "img_k1"},
cardObj{"value": "v2", "img_key": "img_k2"},
},
},
want: "{✓🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
},
{
name: "repeat delegates to elements",
elem: cardObj{
"tag": "repeat",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "item A"},
cardObj{"tag": "markdown", "content": "item B"},
},
},
want: "item A\nitem B",
},
{
name: "audio with file_key",
elem: cardObj{"tag": "audio", "file_key": "file_abc123"},
want: "🎵 Audio(key:file_abc123)",
},
{
name: "audio fallback audio_id",
elem: cardObj{"tag": "audio", "audio_id": "audio_xyz"},
want: "🎵 Audio(key:audio_xyz)",
},
{
name: "video with file_key",
elem: cardObj{"tag": "video", "file_key": "video_abc"},
want: "🎬 Video(key:video_abc)",
},
{
name: "custom_icon returns empty",
elem: cardObj{"tag": "custom_icon", "img_key": "some_key"},
want: "",
},
{
name: "standard_icon returns empty",
elem: cardObj{"tag": "standard_icon", "token": "alarm_outlined"},
want: "",
},
{
name: "button disabled with disabled_tips",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Submit"},
"disabled": true,
"disabled_tips": cardObj{"tag": "plain_text", "content": "Not allowed"},
},
want: "[Submit ✗](tips:\"Not allowed\")",
},
{
name: "button with confirm",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Delete"},
"confirm": cardObj{
"title": cardObj{"tag": "plain_text", "content": "确认"},
"text": cardObj{"tag": "plain_text", "content": "不可撤销"},
},
},
want: "[Delete](confirm:\"确认: 不可撤销\")",
},
{
name: "overflow with url",
elem: cardObj{
"tag": "overflow",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Open"}, "url": "https://example.com"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Copy"}, "value": "copy"},
},
},
want: "⋮ [Open](https://example.com), Copy(copy)",
},
{
name: "select_static with initial_index",
elem: cardObj{
"tag": "select_static",
"initial_index": float64(1),
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "First"}, "value": "a"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Second"}, "value": "b"},
},
},
want: "{First / ✓Second}",
},
{
name: "div text with notation size",
elem: cardObj{
"tag": "div",
"text": cardObj{
"tag": "plain_text",
"content": "小字注释",
"text_size": "notation",
},
},
want: "📝 小字注释",
},
{
name: "form",
elem: cardObj{
"tag": "form",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "fill this"},
},
},
want: "<form>\nfill this\n</form>",
},
{
name: "collapsible_panel collapsed",
elem: cardObj{
"tag": "collapsible_panel",
"expanded": false,
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "inner"},
},
},
want: "▶ Details\n inner\n▲",
},
{
name: "collapsible_panel expanded",
elem: cardObj{
"tag": "collapsible_panel",
"expanded": true,
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "inner"},
},
},
want: "▼ Details\n inner\n▲",
},
{
name: "interactive_container with behaviors",
elem: cardObj{
"tag": "interactive_container",
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com"},
},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "Click here"},
},
},
want: "<clickable url=\"https://example.com\">\nClick here\n</clickable>",
},
{
name: "interactive_container no url",
elem: cardObj{
"tag": "interactive_container",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "No link"},
},
},
want: "<clickable>\nNo link\n</clickable>",
},
{
name: "column_set with buttons → space-joined",
elem: cardObj{
"tag": "column_set",
"columns": []interface{}{
cardObj{"tag": "column", "elements": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "X"}},
}},
cardObj{"tag": "column", "elements": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "Y"}},
}},
},
},
want: "[X] [Y]",
},
{
name: "person",
elem: cardObj{"tag": "person", "user_id": "fake-open-id-002"},
want: "fake-open-id-002",
},
{
name: "unknown tag fallback to content",
elem: cardObj{"tag": "mystery", "content": "mystery content"},
want: "mystery content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.convertElement(tt.elem, 0)
if tt.contains != "" {
if !strings.Contains(got, tt.contains) {
t.Fatalf("convertElement(%s) = %q, want to contain %q", tt.name, got, tt.contains)
}
return
}
if got != tt.want {
t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want)
}
})
}
}
func TestUserDslExtractButtonURL(t *testing.T) {
c := &userDslConverter{}
// direct url field wins first
got := c.extractButtonURL(cardObj{
"url": "https://example.com/direct",
"multi_url": cardObj{"url": "https://example.com/multi"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/direct" {
t.Fatalf("direct url = %q, want https://example.com/direct", got)
}
// multi_url.url when no direct url
got = c.extractButtonURL(cardObj{
"multi_url": cardObj{"url": "https://example.com/multi"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/multi" {
t.Fatalf("multi_url = %q, want https://example.com/multi", got)
}
// behaviors default_url as last resort
got = c.extractButtonURL(cardObj{
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/behavior" {
t.Fatalf("behaviors = %q, want https://example.com/behavior", got)
}
// non-open_url behavior is ignored
got = c.extractButtonURL(cardObj{
"behaviors": []interface{}{
cardObj{"type": "callback", "default_url": "https://example.com/callback"},
},
})
if got != "" {
t.Fatalf("non-open_url = %q, want empty", got)
}
// no url anywhere → empty
got = c.extractButtonURL(cardObj{"text": cardObj{"content": "No URL"}})
if got != "" {
t.Fatalf("no url = %q, want empty", got)
}
}
func TestUserDslExtractTableCellValue(t *testing.T) {
c := &userDslConverter{}
// nil
if got := c.extractUserDslTableCellValue(nil); got != "" {
t.Fatalf("nil = %q, want empty", got)
}
// string
if got := c.extractUserDslTableCellValue("hello"); got != "hello" {
t.Fatalf("string = %q, want 'hello'", got)
}
// float64 integer
if got := c.extractUserDslTableCellValue(float64(42)); got != "42" {
t.Fatalf("int float = %q, want '42'", got)
}
// float64 decimal
if got := c.extractUserDslTableCellValue(float64(3.14)); got != "3.14" {
t.Fatalf("float = %q, want '3.14'", got)
}
// []interface{} with text tags → 「text」 format
got := c.extractUserDslTableCellValue([]interface{}{
cardObj{"text": "S2", "color": "blue"},
cardObj{"text": "M1", "color": "red"},
})
if got != "「S2」 「M1」" {
t.Fatalf("tag array = %q, want '「S2」 「M1」'", got)
}
// cardObj with content field
got = c.extractUserDslTableCellValue(cardObj{"content": "cell content"})
if got != "cell content" {
t.Fatalf("cardObj with content = %q, want 'cell content'", got)
}
}
func TestUserDslConvertTable(t *testing.T) {
c := &userDslConverter{}
got := c.convertTable(cardObj{
"columns": []interface{}{
cardObj{"display_name": "客户名称", "name": "customer_name"},
cardObj{"display_name": "规模", "name": "scale"},
cardObj{"display_name": "金额", "name": "arr"},
},
"rows": []interface{}{
cardObj{
"customer_name": "飞书科技",
"scale": []interface{}{cardObj{"text": "S2", "color": "blue"}},
"arr": float64(16800),
},
},
})
want := "| 客户名称 | 规模 | 金额 |\n|------|------|------|\n| 飞书科技 | 「S2」 | 16800 |"
if got != want {
t.Fatalf("convertTable() = %q, want %q", got, want)
}
// no columns → empty
if got := c.convertTable(cardObj{}); got != "" {
t.Fatalf("no columns = %q, want empty", got)
}
}
func TestLarkMdMentionResolution(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"name": "test-user",
"id": map[string]interface{}{"open_id": "fake-uid-001"},
},
}
// lark_md in div.text — the real Lark event format (C01 case)
card := map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": "Hello <at id=fake-uid-001></at> check this.",
},
},
},
}
dslBytes, _ := json.Marshal(card)
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got := ConvertInteractiveEventContent(string(raw), mentions)
if strings.Contains(got, "<at") {
t.Fatalf("div.text lark_md: raw <at> tag not resolved, got: %s", got)
}
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("div.text lark_md: @id not in output, got: %s", got)
}
// lark_md in note.elements (C02 case)
card = map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "note",
"elements": []interface{}{
map[string]interface{}{
"tag": "lark_md",
"content": "Note: <at id=fake-uid-001></at> check.",
},
},
},
},
}
dslBytes, _ = json.Marshal(card)
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got = ConvertInteractiveEventContent(string(raw), mentions)
if strings.Contains(got, "<at") {
t.Fatalf("note lark_md: raw <at> tag not resolved, got: %s", got)
}
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("note lark_md: @id not in output, got: %s", got)
}
// mention_key resolution via mentions map
card = map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": `Hi <at mention_key="@_user_1">n</at> done.`,
},
},
},
}
dslBytes, _ = json.Marshal(card)
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got = ConvertInteractiveEventContent(string(raw), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("div.text lark_md mention_key: want @test-user(fake-uid-001), got: %s", got)
}
}
func TestConvertUserDslCardEndToEnd(t *testing.T) {
// user-2.ts format — matches structure of docs/user-dsl/user-example-2.json
schema2JSON := `{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "飞书卡片组件展示"},
"template": "blue"
},
"body": {
"elements": [
{"tag": "markdown", "content": "### 基础文本"},
{"tag": "hr"},
{
"tag": "img",
"img_key": "img_v3_02122_abc",
"alt": {"tag": "plain_text", "content": "示例图片"}
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "主要按钮"},
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
},
{
"tag": "table",
"columns": [
{"display_name": "名称", "name": "name"},
{"display_name": "数值", "name": "value"}
],
"rows": [
{"name": "项目A", "value": 100},
{"name": "项目B", "value": 200}
]
}
]
}
}`
got := convertUserDslCard(schema2JSON, nil)
if !strings.HasPrefix(got, `<card title="飞书卡片组件展示">`) {
t.Fatalf("e2e schema2: missing card title prefix, got: %s", got)
}
if !strings.Contains(got, "### 基础文本") {
t.Fatal("e2e schema2: missing markdown content")
}
if !strings.Contains(got, "---") {
t.Fatal("e2e schema2: missing hr")
}
if !strings.Contains(got, "🖼️ 示例图片(img_key:img_v3_02122_abc)") {
t.Fatalf("e2e schema2: missing image, got: %s", got)
}
if !strings.Contains(got, "[主要按钮](https://example.com)") {
t.Fatalf("e2e schema2: missing button, got: %s", got)
}
if !strings.Contains(got, "| 名称 | 数值 |") {
t.Fatal("e2e schema2: missing table header")
}
if !strings.Contains(got, "| 项目A | 100 |") {
t.Fatalf("e2e schema2: missing table row, got: %s", got)
}
if !strings.HasSuffix(got, "</card>") {
t.Fatalf("e2e schema2: missing </card> suffix, got: %s", got)
}
// user-1.ts format
schema1JSON := `{
"i18n_header": {
"zh_cn": {
"title": {"tag": "plain_text", "content": "Schema1 卡片"},
"template": "blue"
}
},
"elements": [
{"tag": "markdown", "content": "Hello **World**"},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "跳转"},
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
}
]
}
]
}`
got = convertUserDslCard(schema1JSON, nil)
if !strings.HasPrefix(got, `<card title="Schema1 卡片">`) {
t.Fatalf("e2e schema1: missing card title, got: %s", got)
}
if !strings.Contains(got, "Hello **World**") {
t.Fatal("e2e schema1: missing markdown")
}
if !strings.Contains(got, "[跳转](https://example.com)") {
t.Fatalf("e2e schema1: missing button, got: %s", got)
}
}

View File

@@ -122,7 +122,7 @@ func TestExtractPostBlocksText(t *testing.T) {
}
got := extractPostBlocksText(blocks)
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
want := "hello @Alice [docs](https://example.com)\n![Image](img_123)"
if got != want {
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
}

View File

@@ -39,16 +39,16 @@ func (postConverter) Convert(ctx *ConvertContext) string {
if title, _ := body["title"].(string); title != "" {
parts = append(parts, title)
}
if blocks, _ := body["content"].([]interface{}); len(blocks) > 0 {
for _, para := range blocks {
elems, _ := para.([]interface{})
var line strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
line.WriteString(renderPostElem(elem))
}
parts = append(parts, line.String())
// Prefer content_v2 blocks; fallback to content blocks
blocks := selectContentBlocks(body)
for _, para := range blocks {
elems, _ := para.([]interface{})
var line strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
line.WriteString(renderPostElem(elem))
}
parts = append(parts, line.String())
}
result := strings.TrimSpace(strings.Join(parts, "\n"))
@@ -58,6 +58,17 @@ func (postConverter) Convert(ctx *ConvertContext) string {
return ResolveMentionKeys(result, ctx.MentionMap)
}
// selectContentBlocks returns content_v2 blocks when present and non-empty;
// otherwise falls back to content blocks. This implements the content_v2
// priority rule for post messages.
func selectContentBlocks(body map[string]interface{}) []interface{} {
if v2, ok := body["content_v2"].([]interface{}); ok && len(v2) > 0 {
return v2
}
blocks, _ := body["content"].([]interface{})
return blocks
}
func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
if _, ok := parsed["content"]; ok {
return parsed
@@ -114,10 +125,14 @@ func renderPostElem(el map[string]interface{}) string {
var rendered string
switch {
case userId == "@_all" || userId == "all":
rendered = "@all"
rendered = `<at user_id="all"></at>`
default:
if name, _ := el["user_name"].(string); name != "" {
rendered = "@" + name
if userId != "" && strings.HasPrefix(userId, "ou") {
rendered = fmt.Sprintf(`<at user_id="%s">%s</at>`, userId, name)
} else {
rendered = "@" + name
}
} else {
rendered = "@" + userId
}
@@ -138,7 +153,7 @@ func renderPostElem(el map[string]interface{}) string {
case "img":
key, _ := el["image_key"].(string)
if key != "" {
return fmt.Sprintf("[Image: %s]", key)
return fmt.Sprintf("![Image](%s)", key)
}
return "[Image]"
case "media":

View File

@@ -93,9 +93,13 @@ func TestRenderPostElem(t *testing.T) {
}{
{name: "text", el: map[string]interface{}{"tag": "text", "text": "hello"}, want: "hello"},
{name: "link", el: map[string]interface{}{"tag": "a", "text": "doc", "href": "https://example.com"}, want: "[doc](https://example.com)"},
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: "@all"},
{name: "mention user", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "[Image: img_123]"},
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: `<at user_id="all"></at>`},
{name: "mention user with id", el: map[string]interface{}{"tag": "at", "user_id": "ou_user_1", "user_name": "Alice"}, want: `<at user_id="ou_user_1">Alice</at>`},
{name: "mention user name only", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
{name: "mention user id only", el: map[string]interface{}{"tag": "at", "user_id": "@_user_1"}, want: "@@_user_1"},
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "![Image](img_123)"},
{name: "image no key", el: map[string]interface{}{"tag": "img"}, want: "[Image]"},
{name: "md text", el: map[string]interface{}{"tag": "md", "text": "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}, want: "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"},
{name: "media", el: map[string]interface{}{"tag": "media", "file_key": "file_123"}, want: "[Media: file_123]"},
{name: "code block", el: map[string]interface{}{"tag": "code_block", "language": "go", "text": "fmt.Println(1)"}, want: "\n```go\nfmt.Println(1)\n```\n"},
{name: "hr", el: map[string]interface{}{"tag": "hr"}, want: "\n---\n"},
@@ -144,3 +148,87 @@ func TestRenderPostElemEmotionStyleMd(t *testing.T) {
})
}
}
func TestSelectContentBlocks(t *testing.T) {
tests := []struct {
name string
body map[string]interface{}
want int
}{
{
name: "content_v2 present and non-empty",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": []interface{}{[]interface{}{map[string]interface{}{"tag": "md", "text": "new"}}},
},
want: 1,
},
{
name: "content_v2 empty array",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": []interface{}{},
},
want: 1,
},
{
name: "content_v2 nil",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
},
want: 1,
},
{
name: "content_v2 wrong type",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": "not_an_array",
},
want: 1,
},
{
name: "both missing",
body: map[string]interface{}{},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selectContentBlocks(tt.body)
if len(got) != tt.want {
t.Fatalf("selectContentBlocks() len = %d, want %d", len(got), tt.want)
}
})
}
}
func TestPostConverterConvertContentV2(t *testing.T) {
// AC-M1-H1: content_v2 present → use content_v2 blocks (md passthrough)
ctx := &ConvertContext{
RawContent: `{"content_v2":[[{"tag":"md","text":"##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}]],"content":[[{"tag":"text","text":"old path"}]]}`,
}
want := "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"
if got := (postConverter{}).Convert(ctx); got != want {
t.Fatalf("postConverter.Convert(content_v2) = %q, want %q", got, want)
}
// AC-M1-H2: no content_v2 → use content blocks with new at/img format
ctx2 := &ConvertContext{
RawContent: `{"content":[[{"tag":"at","user_id":"ou_xxx","user_name":"Bob"},{"tag":"text","text":" "},{"tag":"img","image_key":"img_123"}]]}`,
Mentions: []interface{}{map[string]interface{}{"key": "ou_xxx", "id": "ou_bob", "name": "Bob"}},
}
want2 := `<at user_id="ou_xxx">Bob</at> ![Image](img_123)`
if got := (postConverter{}).Convert(ctx2); got != want2 {
t.Fatalf("postConverter.Convert(content) = %q, want %q", got, want2)
}
// AC-M1-E1: content_v2 empty → fallback to content
ctx3 := &ConvertContext{
RawContent: `{"content_v2":[],"content":[[{"tag":"text","text":"fallback path"}]]}`,
}
want3 := "fallback path"
if got := (postConverter{}).Convert(ctx3); got != want3 {
t.Fatalf("postConverter.Convert(empty content_v2) = %q, want %q", got, want3)
}
}

View File

@@ -29,7 +29,7 @@ var ImChatSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (max 64 chars)"},
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
@@ -50,7 +50,7 @@ var ImChatSearch = common.Shortcut{
Params(params).
Body(body)
},
// Validate enforces query/member-ids presence, --query rune cap, search-types
// Validate enforces query/member-ids presence, search-types
// enum, --member-ids count and format, and --page-size bounds.
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
query := runtime.Str("query")
@@ -58,9 +58,6 @@ var ImChatSearch = common.Shortcut{
if query == "" && memberIDs == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
}
if query != "" && len([]rune(query)) > 64 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
}
if st := runtime.Str("search-types"); st != "" {
allowed := map[string]struct{}{
"private": {},
@@ -151,6 +148,9 @@ var ImChatSearch = common.Shortcut{
"has_more": hasMore,
"page_token": pageToken,
}
if notice, _ := resData["notice"].(string); notice != "" {
outData["notice"] = notice
}
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}

View File

@@ -91,7 +91,7 @@ var ImMessagesSearch = common.Shortcut{
return err
}
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, notice, err := searchMessages(runtime, req)
if err != nil {
return err
}
@@ -103,6 +103,9 @@ var ImMessagesSearch = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "No matching messages found.")
})
@@ -131,6 +134,9 @@ var ImMessagesSearch = common.Shortcut{
"page_token": nextPageToken,
"note": "failed to fetch message details, returning ID list only",
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
for _, id := range messageIds {
@@ -206,6 +212,9 @@ var ImMessagesSearch = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No matching messages found.")
@@ -377,6 +386,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
}, nil
}
// messagesSearchPaginationConfig derives auto-pagination mode and page limit.
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
autoPaginate = runtime.Bool("page-all")
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
@@ -392,7 +402,8 @@ func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginat
return autoPaginate, pageLimit
}
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
// searchMessages fetches message search pages and returns the first server notice.
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, string, error) {
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
pageToken := ""
if tokens := req.params["page_token"]; len(tokens) > 0 {
@@ -410,6 +421,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
lastPageToken string
truncatedByLimit bool
pageCount int
notice string
)
for {
@@ -423,9 +435,12 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
if err != nil {
return nil, false, "", false, pageLimit, err
return nil, false, "", false, pageLimit, "", err
}
if notice == "" {
notice, _ = searchData["notice"].(string)
}
items, _ := searchData["items"].([]interface{})
allItems = append(allItems, items...)
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
@@ -441,9 +456,10 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
pageToken = lastPageToken
}
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, notice, nil
}
// batchMGetMessages fetches message details in API-sized batches.
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
var items []interface{}
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
@@ -457,6 +473,7 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
return items, nil
}
// batchQueryChatContexts fetches chat metadata best-effort for message rows.
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
chatContexts := map[string]map[string]interface{}{}
// Best-effort: a failed chunk only loses its own entries.
@@ -466,6 +483,7 @@ func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) ma
return chatContexts
}
// chunkStrings splits a string slice into fixed-size batches.
func chunkStrings(items []string, chunkSize int) [][]string {
if len(items) == 0 || chunkSize <= 0 {
return nil

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// TestImChatSearchExecutePassesThroughNotice verifies chat search notice output.
func TestImChatSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
longQuery := strings.Repeat("q", 81)
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/chats/search") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
var body map[string]interface{}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("decode request body: %w", err)
}
if got, _ := body["query"].(string); got != longQuery {
return nil, fmt.Errorf("body.query = %q, want %q", got, longQuery)
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
}), nil
}))
runtime.Cmd = newChatSearchNoticeTestCommand(t, longQuery)
runtime.Format = "json"
if err := ImChatSearch.Execute(context.Background(), runtime); err != nil {
t.Fatalf("ImChatSearch.Execute() error = %v", err)
}
data := decodeShortcutData(t, runtime)
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestImMessagesSearchExecutePassesThroughNotice verifies message search notice output.
func TestImMessagesSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
runtime := newMessagesSearchRuntime(t, map[string]string{
"query": "incident",
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{},
"has_more": false,
"page_token": "",
},
}), nil
}))
runtime.Format = "json"
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
}
data := decodeShortcutData(t, runtime)
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// newChatSearchNoticeTestCommand builds a typed chat-search command for notice tests.
func newChatSearchNoticeTestCommand(t *testing.T, query string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"query", "search-types", "member-ids", "sort-by", "page-token"} {
cmd.Flags().String(name, "", "")
}
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted"} {
cmd.Flags().Bool(name, false, "")
}
cmd.Flags().Int("page-size", 20, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("query", query); err != nil {
t.Fatalf("Flags().Set(query) error = %v", err)
}
return cmd
}
// decodeShortcutData extracts the JSON envelope data object from shortcut output.
func decodeShortcutData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
t.Helper()
out, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("stdout buffer has type %T", runtime.Factory.IOStreams.Out)
}
var env map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, out.String())
}
data, ok := env["data"].(map[string]interface{})
if !ok {
t.Fatalf("envelope data missing or wrong type: %#v", env)
}
return data
}

View File

@@ -159,6 +159,7 @@ var MailTriage = common.Shortcut{
var messages []map[string]interface{}
var hasMore bool
var nextPageToken string
var notice string
useSearch, err := resolveTriagePath(parsed, query, filter)
if err != nil {
@@ -189,6 +190,9 @@ var MailTriage = common.Shortcut{
if err != nil {
return err
}
if notice == "" {
notice, _ = searchData["notice"].(string)
}
pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
messages = append(messages, pageMessages...)
pageHasMore, _ := searchData["has_more"].(bool)
@@ -282,8 +286,14 @@ var MailTriage = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
output.PrintJson(runtime.IO().Out, outData)
default: // "table"
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
if len(messages) == 0 {
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
return nil

View File

@@ -1478,14 +1478,16 @@ func boolPtr(v bool) *bool { return &v }
// --- mailbox_id preservation tests ---
// TestMailTriageStructuredOutputPreservesMailboxID verifies mailbox and notice metadata.
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
tests := []struct {
name string
mailbox string
format string
args []string
register func(*httpmock.Registry, string)
wantCount int
name string
mailbox string
format string
args []string
register func(*httpmock.Registry, string)
wantCount int
wantNotice string
}{
{
name: "list json default mailbox",
@@ -1522,9 +1524,10 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageSearchStub(reg, mailbox, []interface{}{
mailTriageSearchItem("search_pub_001", "Shared search"),
}, false, "")
}, false, "", "The query is too long and has been truncated to the first 50 characters for search.")
},
wantCount: 1,
wantCount: 1,
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
},
{
name: "empty list json keeps top-level mailbox",
@@ -1559,6 +1562,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
if data["mailbox_id"] != tt.mailbox {
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
}
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
}
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != tt.wantCount {
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
@@ -1572,6 +1578,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
}
}
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
@@ -1604,6 +1611,7 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
}
}
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
tests := []struct {
name string
@@ -1654,6 +1662,33 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
}
}
// TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr verifies stderr notices.
func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, stderr, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageSearchStub(reg, "me", []interface{}{
mailTriageSearchItem("msg_search_notice", "Search notice result"),
}, false, "", notice)
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--query", strings.Repeat("q", 81),
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "msg_search_notice") {
t.Fatalf("stdout should contain table row, got:\n%s", out)
}
if errOut := stderr.String(); !strings.Contains(errOut, "notice: "+notice) {
t.Fatalf("stderr should contain search notice, got:\n%s", errOut)
}
}
// decodeMailTriageJSONOutput decodes structured triage output for assertions.
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
t.Helper()
var data map[string]interface{}
@@ -1663,6 +1698,7 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
return data
}
// mailTriageMessagesFromOutput extracts triage messages as object maps.
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
t.Helper()
rawMessages, ok := data["messages"].([]interface{})
@@ -1715,7 +1751,8 @@ func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, message
})
}
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
// registerMailTriageSearchStub registers a mailbox search response for triage tests.
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string, notices ...string) {
data := map[string]interface{}{
"items": items,
"has_more": hasMore,
@@ -1723,6 +1760,9 @@ func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items
if pageToken != "" {
data["page_token"] = pageToken
}
if len(notices) > 0 && notices[0] != "" {
data["notice"] = notices[0]
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: mailboxPath(mailbox, "search"),

View File

@@ -308,6 +308,9 @@ var MinutesSearch = common.Shortcut{
"has_more": data["has_more"],
"page_token": data["page_token"],
}
if notice, _ := data["notice"].(string); notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
if len(rows) == 0 {

View File

@@ -609,6 +609,8 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
t.Parallel()
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -617,6 +619,7 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{
nil,
map[string]interface{}{
@@ -641,6 +644,9 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
reg.Verify(t)
var envelope struct {
Data struct {
Notice string `json:"notice"`
} `json:"data"`
Meta struct {
Count int `json:"count"`
} `json:"meta"`
@@ -651,6 +657,9 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
}
if envelope.Data.Notice != notice {
t.Fatalf("data.notice = %q, want %q", envelope.Data.Notice, notice)
}
}
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// batchCreateKR represents a key result in the batch create input.
type batchCreateKR struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
}
// batchCreateObjective represents an objective in the batch create input.
type batchCreateObjective struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
KRs []batchCreateKR `json:"krs,omitempty"`
}
// createdObjective tracks a created objective and its KR IDs for output.
// KRs are automatically deleted by the backend when the objective is deleted (no need to delete them separately during rollback).
type createdObjective struct {
ObjectiveID string
KRIDs []string // for output response only, not used in rollback
}
// parseBatchCreateInput parses and validates the JSON input.
func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
var objectives []batchCreateObjective
if err := json.Unmarshal([]byte(input), &objectives); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be valid JSON array: %s", err).WithParam("--input").WithCause(err)
}
if len(objectives) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must contain at least one objective").WithParam("--input")
}
for i, obj := range objectives {
if strings.TrimSpace(obj.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
}
for j, kr := range obj.KRs {
if strings.TrimSpace(kr.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
}
}
}
return objectives, nil
}
// buildContentBlock converts text and mentions to a ContentBlock.
func buildContentBlock(text string, mentions []string) *ContentBlock {
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
// Add text element
textElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
}
elements = append(elements, textElem)
// Add mention elements
for _, mention := range mentions {
mentionElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &mention,
},
}
elements = append(elements, mentionElem)
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// createObjective calls the API to create an objective.
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
content := buildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{
"content": content,
}
queryParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to create objective")
}
objectiveID, ok := data["objective_id"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
}
return objectiveID, nil
}
// createKR calls the API to create a key result.
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
content := buildContentBlock(kr.Text, kr.Mention)
body := map[string]interface{}{
"content": content,
}
queryParams := map[string]interface{}{
"objective_id": objectiveID,
"user_id_type": userIDType,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to create key result")
}
krID, ok := data["key_result_id"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
}
return krID, nil
}
// deleteObjective deletes an objective (used for rollback).
func deleteObjective(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) error {
queryParams := map[string]interface{}{
"objective_id": objectiveID,
"yes": true,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s", objectiveID)
_, err := runtime.CallAPITyped("DELETE", path, queryParams, nil)
if err != nil {
return wrapOkrNetworkErr(err, "failed to delete objective %s during rollback", objectiveID)
}
return nil
}
// rollback deletes created objectives in reverse order.
// KRs are automatically deleted by the backend when the objective is deleted.
func rollback(ctx context.Context, runtime *common.RuntimeContext, created []createdObjective) []error {
var errsList []error
// Iterate in reverse order
for i := len(created) - 1; i >= 0; i-- {
obj := created[i]
// Delete the objective (backend automatically deletes its KRs)
if err := deleteObjective(ctx, runtime, obj.ObjectiveID); err != nil {
//nolint:forbidigo // intermediate wrap for rollback error collection; final error is typed via buildRollbackError
errsList = append(errsList, fmt.Errorf("objective %s: %w", obj.ObjectiveID, err))
}
// Rate limiting between deletions
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
}
return errsList
}
// OKRBatchCreate batch creates objectives and their key results.
var OKRBatchCreate = common.Shortcut{
Service: "okr",
Command: "+batch-create",
Description: "Batch create OKR objectives and key results with rollback on failure",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
input := runtime.Str("input")
if err := common.RejectDangerousCharsTyped("--input", input); err != nil {
return err
}
if _, err := parseBatchCreateInput(input); err != nil {
return err
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
apis := common.NewDryRunAPI()
for i, obj := range objectives {
// Objective creation
objContent := buildContentBlock(obj.Text, obj.Mention)
objBody := map[string]interface{}{
"content": objContent,
}
objParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
}
objPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
POST(objPath).
Params(objParams).
Body(objBody).
Desc(fmt.Sprintf("Create objective[%d]: %s", i, obj.Text))
// KR creations
for j, kr := range obj.KRs {
krContent := buildContentBlock(kr.Text, kr.Mention)
krBody := map[string]interface{}{
"content": krContent,
}
krParams := map[string]interface{}{
"objective_id": "<objective_id_from_previous_call>",
"user_id_type": userIDType,
}
krPath := "/open-apis/okr/v2/objectives/<objective_id>/key_results"
apis = apis.
POST(krPath).
Params(krParams).
Body(krBody).
Desc(fmt.Sprintf("Create objective[%d].krs[%d]: %s", i, j, kr.Text))
}
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
objectives, err := parseBatchCreateInput(runtime.Str("input"))
if err != nil {
return err
}
var created []createdObjective
for i, obj := range objectives {
// Rate limiting between objectives
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
// Create objective
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
if err != nil {
if len(created) == 0 {
return err
}
rollbackErrs := rollback(ctx, runtime, created)
return buildRollbackError(err, rollbackErrs, created)
}
createdObj := createdObjective{
ObjectiveID: objectiveID,
}
// Create KRs
for j, kr := range obj.KRs {
// Rate limiting between KRs
if j > 0 {
time.Sleep(500 * time.Millisecond)
}
krID, err := createKR(ctx, runtime, objectiveID, userIDType, kr)
if err != nil {
created = append(created, createdObj)
rollbackErrs := rollback(ctx, runtime, created)
return buildRollbackError(err, rollbackErrs, created)
}
createdObj.KRIDs = append(createdObj.KRIDs, krID)
}
created = append(created, createdObj)
}
// Build response
respCreated := make([]map[string]interface{}, 0, len(created))
for _, obj := range created {
respCreated = append(respCreated, map[string]interface{}{
"objective_id": obj.ObjectiveID,
"krs": obj.KRIDs,
})
}
result := map[string]interface{}{
"ok": true,
"data": map[string]interface{}{"created": respCreated},
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully created %d objective(s)\n", len(created))
for i, obj := range created {
fmt.Fprintf(w, "Objective[%d] ID: %s (%d KR(s))\n", i, obj.ObjectiveID, len(obj.KRIDs))
for j, krID := range obj.KRIDs {
fmt.Fprintf(w, " KR[%d] ID: %s\n", j, krID)
}
}
})
return nil
},
}
// buildRollbackError constructs an error that includes both the original failure
// and any rollback failures, with a list of residual IDs that could not be cleaned up.
// KRs are automatically deleted by the backend when the objective is deleted, so we only
// need to track objective IDs for residual cleanup.
func buildRollbackError(originalErr error, rollbackErrs []error, created []createdObjective) error {
var residualIDs []string
// Only collect residual IDs when rollback had failures
// If rollback succeeded (len(rollbackErrs) == 0), all objectives were deleted
if len(rollbackErrs) > 0 {
for _, obj := range created {
residualIDs = append(residualIDs, fmt.Sprintf("objective:%s", obj.ObjectiveID))
}
}
msg := fmt.Sprintf("batch create failed, rolling back: %v", originalErr)
if len(rollbackErrs) > 0 {
var rollbackMsgs []string
for _, e := range rollbackErrs {
rollbackMsgs = append(rollbackMsgs, e.Error())
}
msg += fmt.Sprintf("; rollback also had %d failure(s): %s", len(rollbackErrs), strings.Join(rollbackMsgs, "; "))
}
if len(residualIDs) > 0 {
msg += fmt.Sprintf("; residual objectives that may need manual cleanup (KRs auto-deleted with objective): %s", strings.Join(residualIDs, ", "))
}
// Preserve the original error's type information if it's already a typed error
if prob, ok := errs.ProblemOf(originalErr); ok {
switch prob.Category {
case errs.CategoryAPI:
return errs.NewAPIError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryNetwork:
return errs.NewNetworkError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryValidation:
return errs.NewValidationError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryInternal:
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
default:
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
}
}
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithCause(originalErr)
}

View File

@@ -0,0 +1,593 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-batch-create",
AppSecret: "secret-okr-batch-create",
Brand: core.BrandFeishu,
}
}
func runBatchCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRBatchCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
const validBatchCreateInput = `[
{"text":"Objective 1","mention":["ou_123"],"krs":[{"text":"KR 1.1","mention":["ou_456"]}]},
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
]`
// --- Validate tests ---
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--input", validBatchCreateInput,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "abc",
"--input", validBatchCreateInput,
})
if err == nil {
t.Fatal("expected error for invalid --cycle-id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--cycle-id" {
t.Fatalf("expected param --cycle-id, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_MissingInput(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "input") {
t.Fatalf("expected --input required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidInputJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --input JSON")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyInputArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "[]",
})
if err == nil {
t.Fatal("expected error for empty --input array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyObjectiveText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for empty objective text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].text") {
t.Fatalf("expected error to mention objective[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":""}]}]`,
})
if err == nil {
t.Fatal("expected error for empty KR text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].krs[0].text") {
t.Fatalf("expected error to mention objective[0].krs[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--user-id-type", "invalid",
})
if err == nil {
t.Fatal("expected error for invalid --user-id-type")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--user-id-type" {
t.Fatalf("expected param --user-id-type, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "101",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "201",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "202",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- DryRun tests ---
func TestBatchCreateDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objective creation API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/") || !strings.Contains(output, "/key_results") {
t.Fatalf("dry-run output should contain KR creation API path, got: %s", output)
}
// Verify content is in the body
if !strings.Contains(output, "Objective 1") {
t.Fatalf("dry-run output should contain objective text, got: %s", output)
}
if !strings.Contains(output, "KR 1.1") {
t.Fatalf("dry-run output should contain KR text, got: %s", output)
}
}
// --- Execute tests ---
func TestBatchCreateExecute_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
ok, _ := data["ok"].(bool)
if !ok {
t.Fatal("expected ok=true in output")
}
dataField, _ := data["data"].(map[string]interface{})
created, _ := dataField["created"].([]interface{})
if len(created) != 1 {
t.Fatalf("expected 1 created objective, got %d", len(created))
}
obj, _ := created[0].(map[string]interface{})
if obj["objective_id"] != "100" {
t.Fatalf("expected objective_id=100, got %v", obj["objective_id"])
}
krs, _ := obj["krs"].([]interface{})
if len(krs) != 1 || krs[0] != "200" {
t.Fatalf("expected krs=[200], got %v", krs)
}
}
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1"}]`,
})
if err == nil {
t.Fatal("expected error for API failure")
}
// Should be a typed error from the API
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != "api" {
t.Fatalf("expected api category, got %q", prob.Category)
}
}
func TestBatchCreateExecute_APIErrorOnKR_TriggersRollback(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// First objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback: delete the created objective
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention rollback
if !strings.Contains(err.Error(), "rolling back") && !strings.Contains(err.Error(), "rollback") {
t.Fatalf("expected error to mention rollback, got: %v", err)
}
// Assert typed error metadata
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected api category (preserved from original error), got %q", prob.Category)
}
// Assert cause preservation
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to wrap APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("expected errors.Is to find the wrapped APIError")
}
}
func TestBatchCreateExecute_RollbackDeleteFails(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// Objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback delete also fails
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Status: 500,
Body: map[string]interface{}{
"code": 9999999,
"msg": "internal error",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention residual resources
if !strings.Contains(err.Error(), "residual") && !strings.Contains(err.Error(), "manual cleanup") {
t.Fatalf("expected error to mention residual resources, got: %v", err)
}
if !strings.Contains(err.Error(), "objective:100") {
t.Fatalf("expected error to list residual objective ID, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseBatchCreateInput_Valid(t *testing.T) {
t.Parallel()
input := `[{"text":"Obj 1","mention":["ou_123"],"krs":[{"text":"KR 1","mention":["ou_456"]}]}]`
objs, err := parseBatchCreateInput(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 1 {
t.Fatalf("expected 1 objective, got %d", len(objs))
}
if objs[0].Text != "Obj 1" {
t.Fatalf("expected text 'Obj 1', got %q", objs[0].Text)
}
if len(objs[0].Mention) != 1 || objs[0].Mention[0] != "ou_123" {
t.Fatalf("expected mention ['ou_123'], got %v", objs[0].Mention)
}
if len(objs[0].KRs) != 1 {
t.Fatalf("expected 1 KR, got %d", len(objs[0].KRs))
}
if objs[0].KRs[0].Text != "KR 1" {
t.Fatalf("expected KR text 'KR 1', got %q", objs[0].KRs[0].Text)
}
}
func TestBuildContentBlock(t *testing.T) {
t.Parallel()
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatalf("expected paragraph block type")
}
if block.Paragraph == nil {
t.Fatal("expected non-nil paragraph")
}
// Should have 3 elements: 1 text + 2 mentions
if len(block.Paragraph.Elements) != 3 {
t.Fatalf("expected 3 paragraph elements, got %d", len(block.Paragraph.Elements))
}
// First element should be textRun
if block.Paragraph.Elements[0].ParagraphElementType == nil ||
*block.Paragraph.Elements[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
if block.Paragraph.Elements[0].TextRun == nil || *block.Paragraph.Elements[0].TextRun.Text != "Test text" {
t.Fatalf("expected text 'Test text', got %v", block.Paragraph.Elements[0].TextRun)
}
// Second and third should be mentions
for i := 1; i <= 2; i++ {
if block.Paragraph.Elements[i].ParagraphElementType == nil ||
*block.Paragraph.Elements[i].ParagraphElementType != ParagraphElementTypeMention {
t.Fatalf("expected element %d to be mention", i)
}
}
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"fmt"
"io"
"math"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// parseIndicatorValue parses and validates the indicator value.
func parseIndicatorValue(valueStr string) (float64, error) {
value, err := strconv.ParseFloat(valueStr, 64)
if err != nil {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value").WithCause(err)
}
if math.IsNaN(value) || math.IsInf(value, 0) || value < -99999999999 || value > 99999999999 {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value")
}
return value, nil
}
// fetchIndicatorID fetches the indicator ID for an objective or key result.
// The indicators.list API returns a single indicator object (not a list),
// which always exists (may be a default empty indicator).
func fetchIndicatorID(ctx context.Context, runtime *common.RuntimeContext, level string, id string) (string, error) {
var path string
var params map[string]interface{}
if level == "objective" {
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
params = map[string]interface{}{"page_size": 100}
} else {
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
params = map[string]interface{}{"page_size": 100}
}
data, err := runtime.CallAPITyped("GET", path, params, nil)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to fetch indicators")
}
// Parse response to get indicator ID
// Response format: {"indicator": {"id": "...", ...}} (single object, not a list)
indicator, ok := data["indicator"].(map[string]interface{})
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator field not found in response")
}
indicatorID, ok := indicator["id"].(string)
if !ok || indicatorID == "" {
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator ID not found or empty")
}
return indicatorID, nil
}
// OKRIndicatorUpdate updates the current value of an indicator for an objective or key result.
var OKRIndicatorUpdate = common.Shortcut{
Service: "okr",
Command: "+indicator-update",
Description: "Update the indicator current value for an objective or key result",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to update: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
{Name: "id", Desc: "objective or key result ID (int64), Required."},
{Name: "value", Desc: "new current value for the indicator (number), Required."},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
}
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
id := runtime.Str("id")
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id is required").WithParam("--id")
}
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id must be a valid int64").WithParam("--id")
}
if runtime.Str("value") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--value is required").WithParam("--value")
}
if _, err := parseIndicatorValue(runtime.Str("value")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
id := runtime.Str("id")
value, _ := parseIndicatorValue(runtime.Str("value"))
apis := common.NewDryRunAPI()
var listPath string
if level == "objective" {
listPath = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
} else {
listPath = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
}
// First API: fetch indicator list
apis = apis.
GET(listPath).
Params(map[string]interface{}{"page_size": 100}).
Desc(fmt.Sprintf("Fetch indicators for the %s to get indicator ID", level))
// Second API: patch indicator value
patchPath := "/open-apis/okr/v2/indicators/:indicator_id"
patchBody := map[string]interface{}{
"current_value": value,
}
apis = apis.
PATCH(patchPath).
Body(patchBody).
Set("indicator_id", "<indicator_id_from_list>").
Desc("Update indicator current value")
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
id := runtime.Str("id")
value, err := parseIndicatorValue(runtime.Str("value"))
if err != nil {
return err
}
// Step 1: Fetch indicator ID
indicatorID, err := fetchIndicatorID(ctx, runtime, level, id)
if err != nil {
return err
}
// Step 2: Update indicator value
patchPath := fmt.Sprintf("/open-apis/okr/v2/indicators/%s", indicatorID)
patchBody := map[string]interface{}{
"current_value": value,
}
_, err = runtime.CallAPITyped("PATCH", patchPath, nil, patchBody)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update indicator value")
}
// Build response
result := map[string]interface{}{
"indicator_id": indicatorID,
"current_value": value,
"level": level,
"target_id": id,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Indicator [%s]\n", indicatorID)
fmt.Fprintf(w, " Level: %s\n", level)
fmt.Fprintf(w, " Target ID: %s\n", id)
fmt.Fprintf(w, " Current Value: %v\n", value)
})
return nil
},
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func indicatorUpdateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-indicator-update",
AppSecret: "secret-okr-indicator-update",
Brand: core.BrandFeishu,
}
}
func runIndicatorUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRIndicatorUpdate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestIndicatorUpdateValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--id", "123",
"--value", "50",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "invalid",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for invalid level")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_MissingID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--value", "50",
})
if err == nil || !strings.Contains(err.Error(), "id") {
t.Fatalf("expected --id required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "not-a-number",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for invalid id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--id" {
t.Fatalf("expected param --id, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_MissingValue(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
})
if err == nil || !strings.Contains(err.Error(), "value") {
t.Fatalf("expected --value required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidValue(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "not-a-number",
})
if err == nil {
t.Fatal("expected error for invalid value")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--value" {
t.Fatalf("expected param --value, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "75.5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- Execute tests ---
func TestIndicatorUpdateExecute_Objectives_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
BodyFilter: func(body []byte) bool {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return false
}
val, ok := data["current_value"].(float64)
return ok && val == 75.5
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "75.5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIndicatorUpdateExecute_KeyResults_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/key_results/456/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-789",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-789",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "key-result",
"--id", "456",
"--value", "100",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIndicatorUpdateExecute_FetchAPIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators - API error
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 9999,
"msg": "fetch error",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for fetch API failure")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("errors.Is should find the APIError in the chain")
}
}
func TestIndicatorUpdateExecute_PatchAPIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator - API error
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 9999,
"msg": "patch error",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for patch API failure")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("errors.Is should find the APIError in the chain")
}
}
// --- parseIndicatorValue tests ---
func TestParseIndicatorValue_Valid(t *testing.T) {
t.Parallel()
tests := []string{"0", "100", "75.5", "-10", "0.001", "99999999999"}
for _, v := range tests {
result, err := parseIndicatorValue(v)
if err != nil {
t.Fatalf("expected no error for %q, got: %v", v, err)
}
_ = result
}
}
func TestParseIndicatorValue_Invalid(t *testing.T) {
t.Parallel()
tests := []string{"", "abc", "1e100000", "100000000000"}
for _, v := range tests {
_, err := parseIndicatorValue(v)
if err == nil {
t.Fatalf("expected error for %q", v)
}
}
}

View File

@@ -0,0 +1,448 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// reorderItem is the interface for items that have an ID.
type reorderItem interface {
GetID() string
}
// reorderOp represents a single reorder operation.
type reorderOp struct {
ID string `json:"id"`
Position int32 `json:"position"`
}
// parseReorderOps parses and validates the --ops JSON array.
func parseReorderOps(opsStr string) ([]reorderOp, error) {
var ops []reorderOp
if err := json.Unmarshal([]byte(opsStr), &ops); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must be valid JSON array: %s", err).WithParam("--ops").WithCause(err)
}
if len(ops) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must contain at least one operation").WithParam("--ops")
}
seen := make(map[string]bool)
seenPos := make(map[int32]bool)
for i, op := range ops {
if strings.TrimSpace(op.ID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].id is required and cannot be empty", i).WithParam("--ops")
}
if op.Position <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].position must be a positive integer", i).WithParam("--ops")
}
if seen[op.ID] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --ops", op.ID).WithParam("--ops")
}
if seenPos[op.Position] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate position %d in --ops", op.Position).WithParam("--ops")
}
seen[op.ID] = true
seenPos[op.Position] = true
}
return ops, nil
}
// fetchObjectives fetches all objectives in a cycle.
func fetchObjectives(ctx context.Context, runtime *common.RuntimeContext, cycleID string) ([]Objective, error) {
queryParams := map[string]interface{}{"page_size": "100"}
var objectives []Objective
page := 0
for {
if page > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return nil, wrapOkrNetworkErr(err, "failed to fetch objectives")
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var obj Objective
if err := json.Unmarshal(raw, &obj); err != nil {
continue
}
objectives = append(objectives, obj)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams["page_token"] = pageToken
}
// Sort objectives by position
sort.Slice(objectives, func(i, j int) bool {
pi := int32(0)
if objectives[i].Position != nil {
pi = *objectives[i].Position
}
pj := int32(0)
if objectives[j].Position != nil {
pj = *objectives[j].Position
}
return pi < pj
})
return objectives, nil
}
// fetchKeyResults fetches all key results for an objective.
func fetchKeyResults(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) ([]KeyResult, error) {
queryParams := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
page := 0
for {
if page > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return nil, wrapOkrNetworkErr(err, "failed to fetch key results")
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var kr KeyResult
if err := json.Unmarshal(raw, &kr); err != nil {
continue
}
keyResults = append(keyResults, kr)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams["page_token"] = pageToken
}
// Sort key results by position
sort.Slice(keyResults, func(i, j int) bool {
pi := int32(0)
if keyResults[i].Position != nil {
pi = *keyResults[i].Position
}
pj := int32(0)
if keyResults[j].Position != nil {
pj = *keyResults[j].Position
}
return pi < pj
})
return keyResults, nil
}
// buildReorderedIDs builds the complete ordered ID list from current items and reorder ops.
// Positions are treated as 1-indexed placement keys stored in a map (safe for large values).
// Items are first placed at user-specified positions, remaining items fill empty slots
// in original order starting from position 1, and final output is sorted by position.
func buildReorderedIDs[T reorderItem](items []T, ops []reorderOp, total int) ([]string, error) {
// Create a map of ID to current position
idToPos := make(map[string]int)
for i, item := range items {
idToPos[item.GetID()] = i
}
// Validate all ops IDs exist
for _, op := range ops {
if _, ok := idToPos[op.ID]; !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "id %q not found in current list", op.ID).WithParam("--ops")
}
}
// Use map to store position -> ID (1-indexed, safe for large position values)
posToID := make(map[int]string)
used := make(map[string]bool)
for _, op := range ops {
posToID[int(op.Position)] = op.ID
used[op.ID] = true
}
// Collect unused items in original order
var unused []string
for _, item := range items {
id := item.GetID()
if !used[id] {
unused = append(unused, id)
}
}
// Fill empty slots starting from position 1, in original order
unusedIdx := 0
for pos := 1; unusedIdx < len(unused); pos++ {
if _, occupied := posToID[pos]; !occupied {
posToID[pos] = unused[unusedIdx]
unusedIdx++
}
}
// Collect all positions, sort them, and build result in position order
positions := make([]int, 0, len(posToID))
for pos := range posToID {
positions = append(positions, pos)
}
sort.Ints(positions)
result := make([]string, 0, len(positions))
for _, pos := range positions {
result = append(result, posToID[pos])
}
return result, nil
}
// GetID implements the interface for Objective.
func (o Objective) GetID() string { return o.ID }
// GetID implements the interface for KeyResult.
func (k KeyResult) GetID() string { return k.ID }
// OKRReorder adjusts the position of objectives or key results.
var OKRReorder = common.Shortcut{
Service: "okr",
Command: "+reorder",
Description: "Adjust the position (order) of OKR objectives or key results",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to reorder: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
{Name: "cycle-id", Desc: "OKR cycle ID (int64), Required."},
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
{Name: "ops", Desc: "JSON array of reorder operations: [{\"id\":\"...\",\"position\":1}], Required.", Input: []string{common.File, common.Stdin}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if strings.TrimSpace(level) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
}
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
cycleID := runtime.Str("cycle-id")
if strings.TrimSpace(cycleID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
}
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
if level == "key-result" {
objID := runtime.Str("objective-id")
if objID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
opsStr := runtime.Str("ops")
if strings.TrimSpace(opsStr) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops is required").WithParam("--ops")
}
if err := common.RejectDangerousCharsTyped("--ops", opsStr); err != nil {
return err
}
if _, err := parseReorderOps(opsStr); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, _ := parseReorderOps(runtime.Str("ops"))
apis := common.NewDryRunAPI()
if level == "objective" {
// First fetch objectives
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all objectives in the cycle to determine current order")
// Then reorder
reorderParams := map[string]interface{}{
"cycle_id": cycleID,
}
// Build sample body with placeholder IDs
objectiveIDs := make([]string, 0, len(ops))
for _, op := range ops {
objectiveIDs = append(objectiveIDs, op.ID)
}
reorderBody := map[string]interface{}{
"objective_ids": objectiveIDs,
}
reorderPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
apis = apis.
PUT(reorderPath).
Params(reorderParams).
Body(reorderBody).
Desc("Update objective positions (full list sent, not just changes)")
} else {
// key-result level
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all key results for the objective to determine current order")
reorderParams := map[string]interface{}{
"objective_id": objectiveID,
}
// Build sample body with placeholder IDs
keyResultIDs := make([]string, 0, len(ops))
for _, op := range ops {
keyResultIDs = append(keyResultIDs, op.ID)
}
reorderBody := map[string]interface{}{
"key_result_ids": keyResultIDs,
}
reorderPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
apis = apis.
PUT(reorderPath).
Params(reorderParams).
Body(reorderBody).
Desc("Update key result positions (full list sent, not just changes)")
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, err := parseReorderOps(runtime.Str("ops"))
if err != nil {
return err
}
var reorderedIDs []string
var total int
if level == "objective" {
objectives, err := fetchObjectives(ctx, runtime, cycleID)
if err != nil {
return err
}
total = len(objectives)
reorderedIDs, err = buildReorderedIDs(objectives, ops, total)
if err != nil {
return err
}
// Submit reorder
params := map[string]interface{}{
"cycle_id": cycleID,
}
body := map[string]interface{}{
"objective_ids": reorderedIDs,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update objective positions")
}
} else {
// key-result level
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
if err != nil {
return err
}
total = len(keyResults)
reorderedIDs, err = buildReorderedIDs(keyResults, ops, total)
if err != nil {
return err
}
// Submit reorder
params := map[string]interface{}{
"objective_id": objectiveID,
}
body := map[string]interface{}{
"key_result_ids": reorderedIDs,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update key result positions")
}
}
// Build response
result := map[string]interface{}{
"level": level,
"cycle_id": cycleID,
"total": total,
"ordered": reorderedIDs,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully reordered %d %s(s)\n", total, level)
fmt.Fprintln(w, "New order:")
for i, id := range reorderedIDs {
fmt.Fprintf(w, " Position %d: %s\n", i+1, id)
}
})
return nil
},
}

View File

@@ -0,0 +1,712 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
// testReorderItem implements reorderItem for testing.
type testReorderItem struct {
id string
}
func (t testReorderItem) GetID() string { return t.id }
func reorderTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-reorder",
AppSecret: "secret-okr-reorder",
Brand: core.BrandFeishu,
}
}
func runReorderShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRReorder.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestReorderValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestReorderValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "invalid",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
if err == nil {
t.Fatal("expected error for invalid --level")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestReorderValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--ops", `[{"id":"1","position":1}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestReorderValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
if err == nil {
t.Fatal("expected error for missing --objective-id when --level=key-result")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
}
}
func TestReorderValidate_MissingOps(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "ops") {
t.Fatalf("expected --ops required error, got: %v", err)
}
}
func TestReorderValidate_InvalidOpsJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --ops JSON")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
func TestReorderValidate_EmptyOpsArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", "[]",
})
if err == nil {
t.Fatal("expected error for empty --ops array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
func TestReorderValidate_DuplicateID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1},{"id":"1","position":2}]`,
})
if err == nil {
t.Fatal("expected error for duplicate id in --ops")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected error to mention duplicate id, got: %v", err)
}
}
func TestReorderValidate_DuplicatePosition(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1},{"id":"2","position":1}]`,
})
if err == nil {
t.Fatal("expected error for duplicate position in --ops")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate position") {
t.Fatalf("expected error to mention duplicate position, got: %v", err)
}
}
func TestReorderValidate_NegativePosition(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":0}]`,
})
if err == nil {
t.Fatal("expected error for position <= 0")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
// --- DryRun tests ---
func TestReorderDryRun_Objectives(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
}
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_position") {
t.Fatalf("dry-run output should contain position update API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
}
if !strings.Contains(output, "objective_ids") {
t.Fatalf("dry-run output should contain objective_ids in body, got: %s", output)
}
}
func TestReorderDryRun_KeyResults(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_position") {
t.Fatalf("dry-run output should contain key_results position update API path, got: %s", output)
}
if !strings.Contains(output, "key_result_ids") {
t.Fatalf("dry-run output should contain key_result_ids in body, got: %s", output)
}
}
// --- Execute tests ---
func TestReorderExecute_Objectives_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "3", "position": 3, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock reorder
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "3", "position": 1},
map[string]interface{}{"id": "1", "position": 2},
map[string]interface{}{"id": "2", "position": 3},
},
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"3","position":1}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "objective" {
t.Fatalf("expected level=objective, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
ordered, _ := data["ordered"].([]interface{})
if len(ordered) != 3 {
t.Fatalf("expected 3 items in ordered list, got %d", len(ordered))
}
// First should be 3, then 1, then 2
if ordered[0] != "3" || ordered[1] != "1" || ordered[2] != "2" {
t.Fatalf("expected ordered [3,1,2], got %v", ordered)
}
}
func TestReorderExecute_KeyResults_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch key results
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr1", "position": 1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr2", "position": 2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock reorder
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/objectives/456/key_results_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr2", "position": 1},
map[string]interface{}{"id": "kr1", "position": 2},
},
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--ops", `[{"id":"kr2","position":1}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "key-result" {
t.Fatalf("expected level=key-result, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
ordered, _ := data["ordered"].([]interface{})
if len(ordered) != 2 {
t.Fatalf("expected 2 items in ordered list, got %d", len(ordered))
}
if ordered[0] != "kr2" || ordered[1] != "kr1" {
t.Fatalf("expected ordered [kr2,kr1], got %v", ordered)
}
}
func TestReorderExecute_PositionOutOfRange(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives (only 2 items)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
BodyFilter: func(body []byte) bool {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return false
}
ids, ok := data["objective_ids"].([]interface{})
if !ok || len(ids) != 2 {
return false
}
// position 5 should be clamped to position 2 (last), so order is [2, 1]
return ids[0] == "2" && ids[1] == "1"
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":5}]`, // position 5 exceeds total of 2, should clamp to last
})
if err != nil {
t.Fatalf("unexpected error for out-of-range position (should clamp): %v", err)
}
}
func TestReorderExecute_IDNotFound(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"999","position":1}]`, // ID 999 doesn't exist
})
if err == nil {
t.Fatal("expected error for non-existent ID")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to mention not found, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseReorderOps_Valid(t *testing.T) {
t.Parallel()
ops, err := parseReorderOps(`[{"id":"1","position":2},{"id":"2","position":1}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 ops, got %d", len(ops))
}
if ops[0].ID != "1" || ops[0].Position != 2 {
t.Fatalf("expected op[0] = {1,2}, got %+v", ops[0])
}
if ops[1].ID != "2" || ops[1].Position != 1 {
t.Fatalf("expected op[1] = {2,1}, got %+v", ops[1])
}
}
func TestBuildReorderedIDs(t *testing.T) {
t.Parallel()
items := []Objective{
{ID: "1"},
{ID: "2"},
{ID: "3"},
{ID: "4"},
}
ops := []reorderOp{
{ID: "4", Position: 1},
{ID: "2", Position: 3},
}
result, err := buildReorderedIDs(items, ops, 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 4 at pos1, 1 at pos2 (unchanged), 2 at pos3, 3 at pos4
expected := []string{"4", "1", "2", "3"}
if len(result) != len(expected) {
t.Fatalf("expected %d items, got %d", len(expected), len(result))
}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_SingleClampToEnd(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"},
}
ops := []reorderOp{
{ID: "1", Position: 99}, // position 99 exceeds total of 4, should clamp to last
}
result, err := buildReorderedIDs(items, ops, 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 2 at pos1, 3 at pos2, 4 at pos3, 1 at pos4 (clamped)
expected := []string{"2", "3", "4", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_MultipleClampToEnd(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
}
ops := []reorderOp{
{ID: "1", Position: 10}, // position 10 exceeds total of 5
{ID: "2", Position: 20}, // position 20 exceeds total of 5
}
result, err := buildReorderedIDs(items, ops, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 3 at pos1, 4 at pos2, 5 at pos3, 1 at pos4 (clamped, pos10 < pos20), 2 at pos5 (clamped)
expected := []string{"3", "4", "5", "1", "2"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_MixedClamp(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
}
ops := []reorderOp{
{ID: "5", Position: 1}, // normal position
{ID: "1", Position: 99}, // clamped to end
{ID: "2", Position: 50}, // clamped to end, but position 50 < 99, so comes before 1
}
result, err := buildReorderedIDs(items, ops, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 5 at pos1, 3 at pos2, 4 at pos3, 2 at pos4 (clamped pos50), 1 at pos5 (clamped pos99)
expected := []string{"5", "3", "4", "2", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_LargePositionSafe(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"},
}
// Very large position should not cause memory issues with map-based implementation
ops := []reorderOp{
{ID: "1", Position: 100000000}, // 10^8, would be dangerous with slice
}
result, err := buildReorderedIDs(items, ops, 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 2 at pos1, 3 at pos2, 1 at pos3 (clamped to end)
expected := []string{"2", "3", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}

490
shortcuts/okr/okr_weight.go Normal file
View File

@@ -0,0 +1,490 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// weightItem is the interface for items that have ID and weight.
type weightItem interface {
GetID() string
GetWeight() float64
}
// weightOp represents a single weight assignment.
type weightOp struct {
ID string `json:"id"`
Weight float64 `json:"weight"`
}
// parseWeightOps parses and validates the --weights JSON array.
func parseWeightOps(weightsStr string) ([]weightOp, error) {
var ops []weightOp
if err := json.Unmarshal([]byte(weightsStr), &ops); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must be valid JSON array: %s", err).WithParam("--weights").WithCause(err)
}
if len(ops) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must contain at least one weight assignment").WithParam("--weights")
}
seen := make(map[string]bool)
var sum float64
for i, op := range ops {
if strings.TrimSpace(op.ID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].id is required and cannot be empty", i).WithParam("--weights")
}
if op.Weight < 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be non-negative", i).WithParam("--weights")
}
if op.Weight > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be <= 1", i).WithParam("--weights")
}
// Check for at most 3 decimal places
if math.Round(op.Weight*1000)/1000 != op.Weight {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must have at most 3 decimal places", i).WithParam("--weights")
}
if seen[op.ID] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --weights", op.ID).WithParam("--weights")
}
seen[op.ID] = true
sum += op.Weight
}
// Sum must be <= 1
if sum > 1+1e-9 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "sum of weights must be <= 1, got %.6f", sum).WithParam("--weights")
}
return ops, nil
}
// formatWeight formats a fixed-point weight value as a json.Number with exactly 3 decimal places.
// This ensures precise JSON serialization and avoids float64 precision issues.
func formatWeight(fp int64) json.Number {
return json.Number(fmt.Sprintf("%d.%03d", fp/1000, fp%1000))
}
// normalizeWeights normalizes weights using fixed-point arithmetic (×1000).
// - Specified weights are used as-is (already validated to 3 decimal places).
// - Remaining weight (1 - sum_specified) is distributed to unspecified items
// proportionally based on their original weights.
// - Fixed-point arithmetic ensures exact sum = 1, with residual added to the last item.
// - Weights are returned as json.Number to avoid float64 precision issues in JSON serialization.
func normalizeWeights[T weightItem](
items []T,
ops []weightOp,
) ([]map[string]interface{}, error) {
const scale = 1000 // fixed-point scale for 3 decimal places
// Build map of specified weights (as fixed-point integers)
specified := make(map[string]int64)
var specifiedSum int64
for _, op := range ops {
fp := int64(math.Round(op.Weight * scale))
specified[op.ID] = fp
specifiedSum += fp
}
// Calculate remaining weight to distribute (as fixed-point)
remaining := scale - specifiedSum
if remaining < 0 {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "weight calculation error: remaining weight is negative")
}
// Collect unspecified items and their original weights
type itemWithWeight struct {
item T
fp int64 // original weight as fixed-point
}
var unspecified []itemWithWeight
var originalUnspecifiedSum int64
for _, item := range items {
id := item.GetID()
if _, ok := specified[id]; ok {
continue
}
origWeight := item.GetWeight()
if origWeight < 0 {
origWeight = 0
}
fp := int64(math.Round(origWeight * scale))
unspecified = append(unspecified, itemWithWeight{item: item, fp: fp})
originalUnspecifiedSum += fp
}
// Distribute remaining weight proportionally
result := make([]map[string]interface{}, 0, len(items))
var resultSum int64
// First add specified items in original order
for _, item := range items {
id := item.GetID()
if fp, ok := specified[id]; ok {
result = append(result, map[string]interface{}{
"id": id,
"weight": formatWeight(fp),
})
resultSum += fp
}
}
// Then distribute to unspecified items
if len(unspecified) > 0 && remaining > 0 {
if originalUnspecifiedSum == 0 {
// All original weights are zero, distribute evenly
perItem := remaining / int64(len(unspecified))
residual := remaining - perItem*int64(len(unspecified))
for i, uw := range unspecified {
fp := perItem
// Add residual to the last unspecified item
if i == len(unspecified)-1 {
fp += residual
}
result = append(result, map[string]interface{}{
"id": uw.item.GetID(),
"weight": formatWeight(fp),
})
resultSum += fp
}
} else {
// Distribute proportionally based on original weights
var distributed int64
for i, uw := range unspecified {
var fp int64
if i == len(unspecified)-1 {
// Last item gets the remainder to ensure exact sum
fp = remaining - distributed
} else {
// Proportional distribution
fp = int64(float64(remaining) * float64(uw.fp) / float64(originalUnspecifiedSum))
distributed += fp
}
result = append(result, map[string]interface{}{
"id": uw.item.GetID(),
"weight": formatWeight(fp),
})
resultSum += fp
}
}
} else if remaining > 0 {
// All items were specified, add residual to the last item
if len(result) > 0 {
lastIdx := len(result) - 1
// Parse current weight as fixed-point and add residual
var lastFP int64
if lastWeight, ok := result[lastIdx]["weight"].(json.Number); ok {
if f, err := lastWeight.Float64(); err == nil {
lastFP = int64(math.Round(f * scale))
}
}
result[lastIdx]["weight"] = formatWeight(lastFP + remaining)
resultSum += remaining
}
}
// Verify sum is exactly 1.0
if resultSum != scale {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"weight normalization error: sum is %.6f, expected 1.0", float64(resultSum)/scale)
}
return result, nil
}
// GetWeight implements the interface for Objective.
func (o Objective) GetWeight() float64 {
if o.Weight == nil {
return 0
}
return *o.Weight
}
// GetWeight implements the interface for KeyResult.
func (k KeyResult) GetWeight() float64 {
if k.Weight == nil {
return 0
}
return *k.Weight
}
// OKRWeight adjusts the weight of objectives or key results.
var OKRWeight = common.Shortcut{
Service: "okr",
Command: "+weight",
Description: "Adjust the weight of OKR objectives or key results",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to adjust: objective | key-result", Enum: []string{"objective", "key-result"}, Required: true},
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
{Name: "weights", Desc: "JSON array of weight assignments: [{\"id\":\"...\",\"weight\":0.5}]", Input: []string{common.File, common.Stdin}, Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
cycleID := runtime.Str("cycle-id")
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
if level == "key-result" {
objID := runtime.Str("objective-id")
if objID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
weightsStr := runtime.Str("weights")
if err := common.RejectDangerousCharsTyped("--weights", weightsStr); err != nil {
return err
}
if _, err := parseWeightOps(weightsStr); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, _ := parseWeightOps(runtime.Str("weights"))
apis := common.NewDryRunAPI()
if level == "objective" {
// First fetch objectives
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all objectives in the cycle to get current weights for normalization")
// Then update weights
weightParams := map[string]interface{}{
"cycle_id": cycleID,
}
// Build sample body
objectiveWeights := make([]map[string]interface{}, 0, len(ops))
for _, op := range ops {
objectiveWeights = append(objectiveWeights, map[string]interface{}{
"objective_id": op.ID,
"weight": op.Weight,
})
}
weightBody := map[string]interface{}{
"objective_weights": objectiveWeights,
}
weightPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
apis = apis.
PUT(weightPath).
Params(weightParams).
Body(weightBody).
Desc("Update objective weights (full list sent after normalization)")
} else {
// key-result level
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all key results for the objective to get current weights for normalization")
weightParams := map[string]interface{}{
"objective_id": objectiveID,
}
// Build sample body
keyResultWeights := make([]map[string]interface{}, 0, len(ops))
for _, op := range ops {
keyResultWeights = append(keyResultWeights, map[string]interface{}{
"key_result_id": op.ID,
"weight": op.Weight,
})
}
weightBody := map[string]interface{}{
"key_result_weights": keyResultWeights,
}
weightPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
apis = apis.
PUT(weightPath).
Params(weightParams).
Body(weightBody).
Desc("Update key result weights (full list sent after normalization)")
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, err := parseWeightOps(runtime.Str("weights"))
if err != nil {
return err
}
var normalizedWeights []map[string]interface{}
var total int
if level == "objective" {
objectives, err := fetchObjectives(ctx, runtime, cycleID)
if err != nil {
return err
}
total = len(objectives)
// Validate all specified IDs exist
objIDs := make(map[string]bool)
for _, obj := range objectives {
objIDs[obj.ID] = true
}
for _, op := range ops {
if !objIDs[op.ID] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "objective id %q not found in cycle", op.ID).WithParam("--weights")
}
}
normalizedWeights, err = normalizeWeights(objectives, ops)
if err != nil {
return err
}
// Build position map for sorting
posMap := make(map[string]int32)
for _, obj := range objectives {
if obj.Position != nil {
posMap[obj.ID] = *obj.Position
}
}
// Submit weight update
params := map[string]interface{}{
"cycle_id": cycleID,
}
objectiveWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
for _, w := range normalizedWeights {
objectiveWeights = append(objectiveWeights, map[string]interface{}{
"objective_id": w["id"],
"weight": w["weight"],
})
}
// Sort by position to match API requirements
sort.Slice(objectiveWeights, func(i, j int) bool {
idI := objectiveWeights[i]["objective_id"].(string)
idJ := objectiveWeights[j]["objective_id"].(string)
return posMap[idI] < posMap[idJ]
})
body := map[string]interface{}{
"objective_weights": objectiveWeights,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update objective weights")
}
} else {
// key-result level
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
if err != nil {
return err
}
total = len(keyResults)
// Validate all specified IDs exist
krIDs := make(map[string]bool)
for _, kr := range keyResults {
krIDs[kr.ID] = true
}
for _, op := range ops {
if !krIDs[op.ID] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "key_result id %q not found in objective", op.ID).WithParam("--weights")
}
}
normalizedWeights, err = normalizeWeights(keyResults, ops)
if err != nil {
return err
}
// Build position map for sorting
posMap := make(map[string]int32)
for _, kr := range keyResults {
if kr.Position != nil {
posMap[kr.ID] = *kr.Position
}
}
// Submit weight update
params := map[string]interface{}{
"objective_id": objectiveID,
}
keyResultWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
for _, w := range normalizedWeights {
keyResultWeights = append(keyResultWeights, map[string]interface{}{
"key_result_id": w["id"],
"weight": w["weight"],
})
}
// Sort by position to match API requirements
sort.Slice(keyResultWeights, func(i, j int) bool {
idI := keyResultWeights[i]["key_result_id"].(string)
idJ := keyResultWeights[j]["key_result_id"].(string)
return posMap[idI] < posMap[idJ]
})
body := map[string]interface{}{
"key_result_weights": keyResultWeights,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update key result weights")
}
}
// Build response
result := map[string]interface{}{
"level": level,
"cycle_id": cycleID,
"total": total,
"weights": normalizedWeights,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully updated weights for %d %s(s)\n", total, level)
fmt.Fprintln(w, "Weights:")
for _, weightEntry := range normalizedWeights {
fmt.Fprintf(w, " %s: %v\n", weightEntry["id"], weightEntry["weight"])
}
})
return nil
},
}

View File

@@ -0,0 +1,747 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"math"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
// getWeightFloat extracts a float64 weight from either float64 or json.Number.
func getWeightFloat(v interface{}) float64 {
switch val := v.(type) {
case float64:
return val
case json.Number:
f, _ := val.Float64()
return f
default:
return 0
}
}
func weightTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return &core.CliConfig{
AppID: "test-okr-weight",
AppSecret: "secret-okr-weight",
Brand: core.BrandFeishu,
}
}
func runWeightShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRWeight.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestWeightValidate_MissingLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestWeightValidate_InvalidLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "invalid",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for invalid --level")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestWeightValidate_MissingCycleID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--weights", `[{"id":"1","weight":0.5}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestWeightValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for missing --objective-id when --level=key-result")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
}
}
func TestWeightValidate_MissingWeights(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "weights") {
t.Fatalf("expected --weights required error, got: %v", err)
}
}
func TestWeightValidate_InvalidWeightsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --weights JSON")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_EmptyWeightsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", "[]",
})
if err == nil {
t.Fatal("expected error for empty --weights array")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_NegativeWeight(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":-0.1}]`,
})
if err == nil {
t.Fatal("expected error for negative weight")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "non-negative") {
t.Fatalf("expected error to mention non-negative, got: %v", err)
}
}
func TestWeightValidate_WeightGreaterThanOne(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":1.5}]`,
})
if err == nil {
t.Fatal("expected error for weight > 1")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_TooManyDecimalPlaces(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.1234}]`,
})
if err == nil {
t.Fatal("expected error for weight with more than 3 decimal places")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "3 decimal places") {
t.Fatalf("expected error to mention 3 decimal places, got: %v", err)
}
}
func TestWeightValidate_SumGreaterThanOne(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.6},{"id":"2","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for sum of weights > 1")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "sum of weights") {
t.Fatalf("expected error to mention sum of weights, got: %v", err)
}
}
func TestWeightValidate_DuplicateID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.3},{"id":"1","weight":0.4}]`,
})
if err == nil {
t.Fatal("expected error for duplicate id in --weights")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected error to mention duplicate id, got: %v", err)
}
}
// --- DryRun tests ---
func TestWeightDryRun_Objectives(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5},{"id":"2","weight":0.5}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
}
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_weight") {
t.Fatalf("dry-run output should contain weight update API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
}
if !strings.Contains(output, "objective_weights") {
t.Fatalf("dry-run output should contain objective_weights in body, got: %s", output)
}
}
func TestWeightDryRun_KeyResults(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--weights", `[{"id":"kr1","weight":0.5},{"id":"kr2","weight":0.5}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_weight") {
t.Fatalf("dry-run output should contain key_results weight update API path, got: %s", output)
}
if !strings.Contains(output, "key_result_weights") {
t.Fatalf("dry-run output should contain key_result_weights in body, got: %s", output)
}
}
// --- Execute tests ---
func TestWeightExecute_Objectives_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch objectives
w1 := 0.5
w2 := 0.5
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "weight": &w2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock weight update
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_weight",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": 0.7},
map[string]interface{}{"id": "2", "weight": 0.3},
},
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.7}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "objective" {
t.Fatalf("expected level=objective, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
weights, _ := data["weights"].([]interface{})
if len(weights) != 2 {
t.Fatalf("expected 2 items in weights list, got %d", len(weights))
}
// Verify sum is exactly 1.0
var sum float64
for _, w := range weights {
wm, _ := w.(map[string]interface{})
weightVal := getWeightFloat(wm["weight"])
sum += weightVal
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
}
}
func TestWeightExecute_KeyResults_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch key results
w1 := 0.3
w2 := 0.3
w3 := 0.4
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr1", "weight": &w1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr2", "weight": &w2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr3", "weight": &w3, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock weight update
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/objectives/456/key_results_weight",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--weights", `[{"id":"kr1","weight":0.5}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "key-result" {
t.Fatalf("expected level=key-result, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
weights, _ := data["weights"].([]interface{})
// Verify sum is exactly 1.0
var sum float64
for _, w := range weights {
wm, _ := w.(map[string]interface{})
weightVal := getWeightFloat(wm["weight"])
sum += weightVal
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
}
}
func TestWeightExecute_IDNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch objectives
w1 := 0.5
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"999","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for non-existent ID")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to mention not found, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseWeightOps_Valid(t *testing.T) {
ops, err := parseWeightOps(`[{"id":"1","weight":0.3},{"id":"2","weight":0.7}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 ops, got %d", len(ops))
}
if ops[0].ID != "1" || math.Abs(ops[0].Weight-0.3) > 1e-9 {
t.Fatalf("expected op[0] = {1,0.3}, got %+v", ops[0])
}
if ops[1].ID != "2" || math.Abs(ops[1].Weight-0.7) > 1e-9 {
t.Fatalf("expected op[1] = {2,0.7}, got %+v", ops[1])
}
}
func TestNormalizeWeights_AllSpecified(t *testing.T) {
w1 := 0.0
w2 := 0.0
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
}
ops := []weightOp{
{ID: "1", Weight: 0.3},
{ID: "2", Weight: 0.7},
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
}
func TestNormalizeWeights_PartialSpecified_Proportional(t *testing.T) {
w1 := 0.5
w2 := 0.3
w3 := 0.2
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
{ID: "3", Weight: &w3},
}
ops := []weightOp{
{ID: "1", Weight: 0.4}, // Specify 0.4 for item 1
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 3 {
t.Fatalf("expected 3 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
// Item 1 should have weight 0.4
var item1Weight float64
for _, r := range result {
if r["id"] == "1" {
item1Weight = getWeightFloat(r["weight"])
break
}
}
if math.Abs(item1Weight-0.4) > 1e-9 {
t.Fatalf("expected item 1 weight = 0.4, got %.10f", item1Weight)
}
}
func TestNormalizeWeights_ZeroOriginalWeights(t *testing.T) {
w1 := 0.0
w2 := 0.0
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
}
ops := []weightOp{
{ID: "1", Weight: 0.5},
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
// When original weights are zero, remaining should be distributed evenly
var item2Weight float64
for _, r := range result {
if r["id"] == "2" {
item2Weight = getWeightFloat(r["weight"])
break
}
}
if math.Abs(item2Weight-0.5) > 1e-9 {
t.Fatalf("expected item 2 weight = 0.5 (even distribution), got %.10f", item2Weight)
}
}

View File

@@ -18,5 +18,9 @@ func Shortcuts() []common.Shortcut {
OKRUpdateProgressRecord,
OKRDeleteProgressRecord,
OKRUploadImage,
OKRBatchCreate,
OKRReorder,
OKRWeight,
OKRIndicatorUpdate,
}
}

View File

@@ -11,5 +11,6 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesScreenshot,
}
}

View File

@@ -0,0 +1,537 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// SlidesScreenshot fetches server-rendered slide screenshots and writes them to
// local files. The raw API returns Base64 image payloads; this shortcut keeps
// those payloads out of stdout so agents only see small file metadata.
var SlidesScreenshot = common.Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
{Name: "output-name", Desc: "file name stem for --content render output"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
renderMode := runtime.Changed("content")
if renderMode {
if strings.TrimSpace(runtime.Str("content")) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
}
} else {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
return err
}
if !hasSlideScreenshotSelector(runtime) {
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
}
}
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if runtime.Changed("content") {
return dryRunRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir")))
}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
dry.POST(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)).
Body(body)
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("content") {
return executeRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
if err != nil {
return err
}
url := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)
query := larkcore.QueryParams{}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", url, query, body)
if err != nil {
return enrichSlidesScreenshotSelectorError(err, slideNumbers)
}
saved, err := saveSlideScreenshots(runtime, data, safeOutputDir, presentationID)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"xml_presentation_id": presentationID,
"output_dir": outputDir,
"screenshots": saved,
}, nil)
return nil
},
}
func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content")
}
dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file")
dry.POST("/open-apis/slides_ai/v1/slide_image/render").
Body(map[string]interface{}{
"content": fmt.Sprintf("<xml omitted; length=%d>", len(content)),
})
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution")
}
func executeRenderScreenshot(runtime *common.RuntimeContext) error {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
if err != nil {
return err
}
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
saved, err := saveRenderedSlideScreenshot(runtime, data, safeOutputDir, runtime.Str("output-name"))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"output_dir": outputDir,
"screenshots": saved,
}, nil)
return nil
}
func normalizeSlideIDs(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, v := range values {
s := strings.TrimSpace(v)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
func normalizeSlideNumbers(values []int) ([]int, error) {
out := make([]int, 0, len(values))
seen := map[int]struct{}{}
for _, n := range values {
if n < 1 {
return nil, slidesScreenshotFlagErrorf("--slide-number must be a positive integer")
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out, nil
}
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
}
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func validateScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
if _, err := runtime.ResolveSavePath(filepath.Join(outputDir, "probe.png")); err != nil {
return "", slidesScreenshotFlagErrorf("--output-dir invalid: %v", err)
}
return outputDir, nil
}
func ensureScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
return validateScreenshotOutputDir(runtime, outputDir)
}
func saveSlideScreenshots(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, presentationID string) ([]map[string]interface{}, error) {
items := common.GetSlice(data, "slide_images")
if len(items) == 0 {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images")
}
saved := make([]map[string]interface{}, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i)
}
item, err := saveSlideScreenshotImage(runtime, m, outputDir, slideScreenshotListFileBase(presentationID, m, i), "")
if err != nil {
if isSlidesScreenshotPassthroughError(err) {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err)
}
saved = append(saved, item)
}
return saved, nil
}
func saveRenderedSlideScreenshot(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) {
item := common.GetMap(data, "slide_image")
if item == nil {
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image")
}
saved, err := saveSlideScreenshotImage(runtime, item, outputDir, outputName, "rendered-slide")
if err != nil {
if isSlidesScreenshotPassthroughError(err) {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err)
}
return []map[string]interface{}{saved}, nil
}
func saveSlideScreenshotImage(runtime *common.RuntimeContext, item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) {
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
ext, label, err := slideScreenshotFormat(item)
if err != nil {
return nil, slidesScreenshotImageDataError(slideID, "%s", err)
}
encoded := strings.TrimSpace(common.GetString(item, "data"))
if encoded == "" {
return nil, slidesScreenshotImageDataError(slideID, "empty image data")
}
imageBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, slidesScreenshotImageDataCauseError(slideID, err, "decode screenshot: %s", err)
}
fileBase := strings.TrimSpace(outputName)
if fileBase == "" {
fileBase = slideID
}
if fileBase == "" {
fileBase = fallbackName
}
path, err := writeUniqueScreenshotFile(runtime, outputDir, fileBase, ext, imageBytes)
if err != nil {
return nil, err
}
return map[string]interface{}{
"slide_id": slideID,
"slide_number": slideScreenshotInt(item, "slide_number"),
"format": label,
"path": path,
"size": len(imageBytes),
}, nil
}
func slideScreenshotListFileBase(presentationID string, item map[string]interface{}, index int) string {
presentationID = strings.TrimSpace(presentationID)
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
slideNumber := slideScreenshotInt(item, "slide_number")
if presentationID != "" {
switch {
case slideNumber > 0 && slideID != "":
return fmt.Sprintf("%s_p%03d_%s", presentationID, slideNumber, slideID)
case slideNumber > 0:
return fmt.Sprintf("%s_p%03d", presentationID, slideNumber)
case slideID != "":
return fmt.Sprintf("%s_%s", presentationID, slideID)
}
}
if slideID != "" {
return slideID
}
if slideNumber := slideScreenshotInt(item, "slide_number"); slideNumber > 0 {
return fmt.Sprintf("slide-%d", slideNumber)
}
return fmt.Sprintf("slide-%d", index+1)
}
func slideScreenshotFormat(item map[string]interface{}) (string, string, error) {
format := slideScreenshotInt(item, "format")
switch format {
case 1:
return "png", "png", nil
case 2:
return "jpg", "jpeg", nil
default:
return "", "", errs.NewAPIError(errs.SubtypeInvalidResponse, "unsupported screenshot format %d", format)
}
}
func slidesScreenshotImageDataError(slideID string, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if slideID != "" {
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
}
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg)
}
func slidesScreenshotImageDataCauseError(slideID string, cause error, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if slideID != "" {
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
}
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg).WithCause(cause)
}
func slideScreenshotInt(item map[string]interface{}, key string) int {
n, ok := util.ToFloat64(item[key])
if !ok {
return 0
}
return int(n)
}
func doSlidesScreenshotAPIJSONWithLogID(runtime *common.RuntimeContext, method string, apiPath string, query larkcore.QueryParams, body interface{}) (map[string]interface{}, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
QueryParams: query,
}
if body != nil {
req.Body = body
}
resp, err := runtime.DoAPI(req)
if err != nil {
return nil, errs.WrapInternal(err)
}
data, err := runtime.ClassifyAPIResponse(resp)
if err != nil {
return data, err
}
if data == nil {
data = map[string]interface{}{}
}
if logID := strings.TrimSpace(resp.Header.Get("x-tt-logid")); logID != "" {
data["log_id"] = logID
}
return data, nil
}
func enrichSlidesScreenshotSelectorError(err error, slideNumbers []int) error {
if len(slideNumbers) == 0 {
return err
}
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
if p.Hint == "" {
p.Hint = "slide_numbers was rejected by the server; verify the page number exists in this presentation, or retry with --slide-id."
}
return err
}
func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewAPIError(errs.SubtypeInvalidResponse, "%s; raw_data=%v", msg, summarizeScreenshotAPIData(data))
if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" {
err = err.WithLogID(logID)
}
return err
}
func isSlidesScreenshotPassthroughError(err error) bool {
_, ok := errs.ProblemOf(err)
return ok
}
func summarizeScreenshotAPIData(v interface{}) interface{} {
switch x := v.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[k] = summarizeScreenshotAPIData(val)
}
return out
case []interface{}:
out := make([]interface{}, 0, len(x))
for i, val := range x {
if i >= 20 {
out = append(out, fmt.Sprintf("<omitted %d more items>", len(x)-i))
break
}
out = append(out, summarizeScreenshotAPIData(val))
}
return out
case string:
if len(x) > 512 {
return fmt.Sprintf("<omitted string length=%d prefix=%q>", len(x), x[:64])
}
return x
default:
return x
}
}
func safeScreenshotFileBase(base string) string {
name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_")
name = strings.Trim(name, "._-")
if name == "" {
name = "slide"
}
return name
}
func writeUniqueScreenshotFile(runtime *common.RuntimeContext, outputDir string, fileBase string, ext string, imageBytes []byte) (string, error) {
base := safeScreenshotFileBase(fileBase)
for i := 0; i < 1000; i++ {
candidateBase := base
if i > 0 {
candidateBase = fmt.Sprintf("%s_%d", base, i+1)
}
path := filepath.Join(outputDir, candidateBase+"."+ext)
if _, err := runtime.FileIO().Stat(path); err == nil {
continue
} else if !isScreenshotFileNotExist(err) {
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: %v", path, err).WithCause(err)
}
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(imageBytes)); err != nil {
return "", common.WrapSaveErrorTyped(err)
}
resolvedPath, err := runtime.ResolveSavePath(path)
if err != nil {
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve saved screenshot path %s: %v", path, err).WithCause(err)
}
return resolvedPath, nil
}
path := filepath.Join(outputDir, base+"."+ext)
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: too many duplicate file names", path)
}
func isScreenshotFileNotExist(err error) bool {
return os.IsNotExist(err)
}

View File

@@ -0,0 +1,506 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
imageBytes := []byte("png-bytes")
jpegBytes := []byte("jpeg-bytes")
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString(imageBytes),
},
{
"slide_id": "slide_2",
"slide_number": 2,
"format": 2,
"data": base64.StdEncoding.EncodeToString(jpegBytes),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1",
"--output-dir", "shots",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "shots", "pres_abc_slide_1.png")
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read screenshot: %v", err)
}
if string(gotBytes) != string(imageBytes) {
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
}
jpegPath := filepath.Join(dir, "shots", "pres_abc_p002_slide_2.jpg")
gotJPEGBytes, err := os.ReadFile(jpegPath)
if err != nil {
t.Fatalf("read jpeg screenshot: %v", err)
}
if string(gotJPEGBytes) != string(jpegBytes) {
t.Fatalf("written jpeg bytes = %q, want %q", gotJPEGBytes, jpegBytes)
}
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
items, ok := data["screenshots"].([]interface{})
if !ok || len(items) != 2 {
t.Fatalf("screenshots = %#v, want two items", data["screenshots"])
}
item, _ := items[0].(map[string]interface{})
if item["slide_id"] != "slide_1" {
t.Fatalf("slide_id = %v, want slide_1", item["slide_id"])
}
gotPath := item["path"].(string)
if !filepath.IsAbs(gotPath) {
t.Fatalf("path = %v, want absolute path", gotPath)
}
if !strings.HasSuffix(gotPath, filepath.Join("shots", "pres_abc_slide_1.png")) {
t.Fatalf("path = %v, want shots/pres_abc_slide_1.png suffix", item["path"])
}
item2, _ := items[1].(map[string]interface{})
if item2["format"] != "jpeg" {
t.Fatalf("format = %v, want jpeg", item2["format"])
}
gotPath2 := item2["path"].(string)
if !filepath.IsAbs(gotPath2) {
t.Fatalf("path = %v, want absolute path", gotPath2)
}
if !strings.HasSuffix(gotPath2, filepath.Join("shots", "pres_abc_p002_slide_2.jpg")) {
t.Fatalf("path = %v, want shots/pres_abc_p002_slide_2.jpg suffix", item2["path"])
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 1 || body.SlideIDs[0] != "slide_1" {
t.Fatalf("slide_ids = %#v, want [slide_1]", body.SlideIDs)
}
}
func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_number": 2,
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes")),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "2",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideNumbers []int `json:"slide_numbers"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideNumbers) != 1 || body.SlideNumbers[0] != 2 {
t.Fatalf("slide_numbers = %#v, want [2]", body.SlideNumbers)
}
path := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_p002.png")
if _, err := os.ReadFile(path); err != nil {
t.Fatalf("read screenshot without slide_id: %v", err)
}
}
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
outputDir := filepath.Join(dir, "shots")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
t.Fatalf("create output dir: %v", err)
}
existingPath := filepath.Join(outputDir, "pres_abc_p002.png")
if err := os.WriteFile(existingPath, []byte("existing"), 0o644); err != nil {
t.Fatalf("write existing screenshot: %v", err)
}
imageBytes := []byte("new-png")
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_number": 2,
"format": 1,
"data": base64.StdEncoding.EncodeToString(imageBytes),
},
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "2",
"--output-dir", "shots",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
gotExisting, err := os.ReadFile(existingPath)
if err != nil {
t.Fatalf("read existing screenshot: %v", err)
}
if string(gotExisting) != "existing" {
t.Fatalf("existing screenshot = %q, want unchanged", gotExisting)
}
newPath := filepath.Join(outputDir, "pres_abc_p002_2.png")
gotNew, err := os.ReadFile(newPath)
if err != nil {
t.Fatalf("read deduplicated screenshot: %v", err)
}
if string(gotNew) != string(imageBytes) {
t.Fatalf("deduplicated screenshot = %q, want %q", gotNew, imageBytes)
}
data := decodeShortcutData(t, stdout)
items, ok := data["screenshots"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
}
item, _ := items[0].(map[string]interface{})
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "pres_abc_p002_2.png")) {
t.Fatalf("path = %v, want shots/pres_abc_p002_2.png suffix", item["path"])
}
}
func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--slide-id or --slide-number is required") {
t.Fatalf("error = %v, want missing selector error", err)
}
}
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
content := `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`
if err := os.WriteFile(filepath.Join(dir, "slide.xml"), []byte(content), 0o644); err != nil {
t.Fatalf("write input xml: %v", err)
}
imageBytes := []byte("rendered-png")
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/slide_image/render",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_image": map[string]interface{}{
"slide_id": "render_slide",
"slide_number": 1,
"format": 1,
"data": base64.StdEncoding.EncodeToString(imageBytes),
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", "@slide.xml",
"--output-dir", "shots",
"--output-name", "preview",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "shots", "preview.png")
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read rendered screenshot: %v", err)
}
if string(gotBytes) != string(imageBytes) {
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
}
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
}
var body struct {
Content string `json:"content"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body.Content != content {
t.Fatalf("content = %q, want input XML", body.Content)
}
data := decodeShortcutData(t, stdout)
items, ok := data["screenshots"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
}
item, _ := items[0].(map[string]interface{})
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "preview.png")) {
t.Fatalf("path = %v, want shots/preview.png suffix", item["path"])
}
}
func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--slide-id", "slide_1",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
t.Fatalf("error = %v, want content/slide selector conflict", err)
}
}
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--presentation cannot be used with --content") {
t.Fatalf("error = %v, want presentation/content conflict", err)
}
}
func TestSlidesScreenshotDryRunSelectsListOrRenderAPI(t *testing.T) {
t.Run("list", func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "2",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/xml_presentations/pres_abc/slide_images") {
t.Fatalf("dry-run missing list endpoint: %s", out)
}
if !strings.Contains(out, "slide_numbers") {
t.Fatalf("dry-run missing slide_numbers body: %s", out)
}
})
t.Run("render", func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/slide_image/render") {
t.Fatalf("dry-run missing render endpoint: %s", out)
}
if !strings.Contains(out, "base64_output") {
t.Fatalf("dry-run missing base64 suppression note: %s", out)
}
})
}
func TestSlidesScreenshotRejectsBadOutputDir(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1",
"--output-dir", "../outside",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for unsafe output dir")
}
if !strings.Contains(err.Error(), "--output-dir invalid") {
t.Fatalf("error = %v, want output-dir validation", err)
}
}
func TestSlidesScreenshotNoImagesErrorIncludesRawDataAndLogID(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Headers: map[string][]string{
"Content-Type": {"application/json"},
"X-Tt-Logid": {"log-123"},
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"unexpected": "shape",
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "pJJ",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed problem", err)
}
if p.LogID != "log-123" {
t.Fatalf("log_id = %v, want log-123", p.LogID)
}
if !strings.Contains(p.Message, "unexpected:shape") {
t.Fatalf("message = %q, want raw_data summary", p.Message)
}
}
func TestSlidesScreenshotSlideNumberAPIErrorAddsHint(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Headers: map[string][]string{
"Content-Type": {"application/json"},
"X-Tt-Logid": {"log-slide-number"},
},
Body: map[string]interface{}{
"code": 99992402,
"msg": "field validation failed",
},
})
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "25",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed problem", err)
}
if p.LogID != "log-slide-number" {
t.Fatalf("log_id = %v, want log-slide-number", p.LogID)
}
if !strings.Contains(p.Hint, "--slide-id") {
t.Fatalf("hint = %q, want --slide-id guidance", p.Hint)
}
}

Some files were not shown because too many files have changed in this diff Show More