From 2d38beb7befd00727614af79e1c86134e61230ce Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 21:51:21 +0800 Subject: [PATCH] fix(shortcuts): separate compatibility visibility from availability --- .../builtin/ding_semantic_catalog_test.go | 11 ++++-- internal/shortcut/ding/common.go | 5 ++- internal/shortcut/ding/ding.go | 8 ++-- internal/shortcut/semantic_catalog.go | 11 +++--- .../semantic_catalog_coverage_test.go | 37 +++++++++++-------- internal/shortcut/semantic_catalog_ding.json | 6 ++- 6 files changed, 48 insertions(+), 30 deletions(-) diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go index faf2408c..af96a75d 100644 --- a/internal/shortcut/builtin/ding_semantic_catalog_test.go +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -37,6 +37,11 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( "+recall-personal": true, "+send-personal": true, } + wantCompatibilityAvailability := map[string]shortcut.Availability{ + "+list": shortcut.AvailabilityUnavailable, + "+recall-personal": shortcut.AvailabilityAvailable, + "+send-personal": shortcut.AvailabilityAvailable, + } public, unavailable, compatibilityVisible := 0, 0, 0 var missing, stale []string for command, item := range registered { @@ -66,7 +71,7 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( } else { if record.CompatibilityVisible { compatibilityVisible++ - if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("ding", command) { + if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) { t.Errorf("%s compatibility-visible boundary drift", command) } } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) { @@ -90,7 +95,7 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 4 || compatibilityVisible != len(wantCompatibilityVisible) { - t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/4/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go index bb45a46c..13408567 100644 --- a/internal/shortcut/ding/common.go +++ b/internal/shortcut/ding/common.go @@ -18,8 +18,9 @@ import ( ) const ( - dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." - dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." + dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." + dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." ) type dingPageEvidence struct { diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index 8c923b92..ba2a19a5 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -101,7 +101,7 @@ var ReceiverStatus = shortcut.Shortcut{ var SendPersonal = compatibilityDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", - "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", + "仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。", shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, @@ -152,7 +152,7 @@ var SendByMessage = unavailableDingWrite( var RecallPersonal = compatibilityDingWrite( "+recall-personal", "撤回本人发起的 DING", - "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", + "仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。", shortcut.RiskHighWrite, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, []contract.ParamDecl{{Name: "id", Property: "id"}}, @@ -163,11 +163,13 @@ var RecallPersonal = compatibilityDingWrite( ) func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + declaration := dingContract(command, description, intent, true, nil, nil, params, example) + declaration.Interface.Reason = dingCompatibilityWriteReason return shortcut.Shortcut{ Service: "ding", Command: command, Product: "im", Description: description, Intent: intent, Risk: risk, Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, - Contract: dingContract(command, description, intent, true, nil, nil, params, example), + Contract: declaration, Flags: flags, Tips: []string{example}, Execute: execute, } } diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 2542142f..60aa42c6 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -142,11 +142,12 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { command, record.Availability)) } // A command that was already part of the visible CLI contract cannot be - // hidden in the same feature change merely because Agent publication is - // withdrawn. This narrow fact preserves historical discovery/argv while - // the unavailable Interface and public=false keep it out of Agent routes. - if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) { - panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command)) + // hidden merely because Agent publication is withdrawn. Compatibility + // visibility owns only historical CLI discovery; availability independently + // records whether that compatibility path still executes. public=false keeps + // both available and unavailable compatibility leaves out of Agent routes. + if record.CompatibilityVisible && record.Public { + panic(fmt.Sprintf("semantic catalog command %q cannot be both public and compatibility-visible", command)) } key := publicCatalogKey(source.Service, command) if _, exists := out[key]; exists { diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index d555aced..df822179 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -136,21 +136,6 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } }`, - "compatibility-visible available": `{ - "version": 1, - "service": "chat", - "default_availability": "available", - "shortcuts": { - "+messages": { - "disposition": "semantic_adapter", - "semantic_delta": "reviewed", - "risk": "read", - "public": false, - "compatibility_visible": true, - "reviewed": true - } - } - }`, } original := semanticCatalogJSON t.Cleanup(func() { semanticCatalogJSON = original }) @@ -210,6 +195,28 @@ func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) { if InPublicCatalog(item.Service, item.Command) { t.Fatal("compatibility-visible unavailable shortcut entered the public catalog") } + + available := map[string]semanticCatalogRecord{} + loadSemanticCatalog([]byte(`{ + "version":1, + "service":"compatibility-test", + "default_availability":"unavailable", + "shortcuts":{ + "+legacy":{ + "disposition":"semantic_adapter", + "semantic_delta":"historical CLI path remains executable but is not Agent-public", + "risk":"write", + "availability":"available", + "public":false, + "compatibility_visible":true, + "reviewed":true + } + } + }`), available) + record := available[publicCatalogKey("compatibility-test", "+legacy")] + if record.Public || !record.CompatibilityVisible || record.Availability != AvailabilityAvailable { + t.Fatalf("available compatibility record = %#v", record) + } } func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index d2abf1a8..7c1565d5 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -21,8 +21,9 @@ }, "+send-personal": { "disposition": "semantic_adapter", - "semantic_delta": "已修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;隔离自收件 APP fixture 返回稳定 openDingId 并可按该 ID 读回,但接收项只有显示名而没有稳定接收人身份,且撤回无可查询终态,保持不可用。", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。", "risk": "write", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true @@ -36,8 +37,9 @@ }, "+recall-personal": { "disposition": "semantic_adapter", - "semantic_delta": "个人 DING 撤回返回布尔成功但不回显目标或终态;撤回后精确接收状态仍保留原记录,而消息列表分页已知不可靠,不能把首屏缺席解释为全局终态,保持不可用。", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。", "risk": "high-risk-write", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true