Files
dingtalk-workspace-cli/internal/cli/schema_agent_selection_test.go
T
玉澜 e705012011 feat(schema): migrate parameter overlays into code declarations
Move 121/210 parameter-level Schema field overlays from
schema_hints/metadata JSON into in-code ParamDecl declarations on
DeclareLeafMetadata commands. The declared values are emitted as
dws.schema.* annotations at assembly time via ApplyParamDecls,
outranking tool_schema_hint (rank 620 > 500) so the hint overlay
becomes redundant once the declaration is in place.

Key mechanism changes:
- Add SchemaDecl.Parameters []ParamDecl with property/required/
  interface_type/description/required_when/enum fields
- Add cli.ParamDecl type carried inside ContractFinalPayload
- ApplyParamDecls emits dws.schema.* annotations from the payload
  at assembly time (no sync.Map, no tree-rebuild key issue)
- Add cli.AnnotateRuntimeFlagInterfaceType (41 overlays needed it)
- Add cli.AnnotateRuntimeFlagRequiredValue for explicit true/false
- Compatibility alias check tolerates dws.schema.* annotation
  differences between primary and alias commands
- Remove runtime source_hash recomputation (87ms/997k allocs saved;
  enforced by check-generated-drift.sh at build time instead)
- Add shortcut.Flag.RequiredWhen and wire through FromShortcut
- Add boolFlag OR semantics to fix confirmationBypass disagreement
- Add bindKey default kebab-to-camel for forgotten Bind
- Add schema consumption benchmarks (catalog decode 1.5s/817MB,
  shortcut load 1.9ms/3MB — three orders of magnitude apart)
- Add catalog codegen feasibility probe (34 tools: 0.06s compile,
  31ns access, 87KB linked — extrapolates to 2.1MB for 845 tools)

Migrated products (29 tools, 121 fields):
  aisearch(1), chat(12), contact(4), doc(3), drive(1),
  hrbrain(10), mail(2), report(1), sheet(3), todo(6)

Remaining 89 fields across 11 products blocked by:
  - RPCName not found as string literal (19 tools, shortcut/variable)
  - No matching DeclareLeafMetadata near callMCPTool (11 tools)
  - No single RPCName for multi-step commands (drive.upload etc.)

All tests green: corecmd, cli, helpers. Generation and drift clean.
2026-08-01 09:25:40 +08:00

166 lines
6.1 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestBuildManualAgentSelectionEvalFixtureUsesReviewedScenariosAndRealCommands(t *testing.T) {
bound := manualAgentSelectionBoundFixture()
hints := manualAgentHintSetFixture()
fixture, report, err := BuildManualAgentSelectionEvalFixture(bound, hints)
if err != nil {
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
}
if report.Tools != 1 || report.PositiveAssertions != 1 || report.NegativeAssertions != 1 {
t.Fatalf("selection report = %+v", report)
}
if report.FixtureSHA256 == "" || len(fixture.Cases) != 2 {
t.Fatalf("selection fixture = %+v, report = %+v", fixture, report)
}
positive, negative := fixture.Cases[0], fixture.Cases[1]
if positive.ExpectedCanonical != "sample.search_items" || positive.ForbiddenCanonical != "" {
t.Fatalf("positive case = %+v", positive)
}
if negative.ForbiddenCanonical != "sample.search_items" || negative.ExpectedCanonical != "" {
t.Fatalf("negative case = %+v", negative)
}
if !reflect.DeepEqual(positive.CandidateCanonicals, []string{"sample.search_items"}) {
t.Fatalf("positive candidates = %#v", positive.CandidateCanonicals)
}
_, repeated, err := BuildManualAgentSelectionEvalFixture(bound, hints)
if err != nil || repeated.FixtureSHA256 != report.FixtureSHA256 {
t.Fatalf("repeated fixture digest = %q, err=%v; want %q", repeated.FixtureSHA256, err, report.FixtureSHA256)
}
}
func TestCrossPlatformCoverageBuildManualAgentSelectionEvalFixtureRejectsFactualContractDrift(t *testing.T) {
tests := []struct {
name string
mutate func(*BoundCommandRegistry, *ManualAgentHintSet)
wantErr string
}{
{
name: "missing reviewed tool",
mutate: func(_ *BoundCommandRegistry, hints *ManualAgentHintSet) {
delete(hints.Tools, "sample.search_items")
},
wantErr: "selection tools do not exactly match",
},
{
name: "unbound primary command",
mutate: func(bound *BoundCommandRegistry, _ *ManualAgentHintSet) {
item := bound.ByCanonical["sample.search_items"]
item.PrimaryCommand = nil
bound.ByCanonical["sample.search_items"] = item
},
wantErr: "no bound primary Cobra command",
},
{
name: "literal positive negative contradiction",
mutate: func(_ *BoundCommandRegistry, hints *ManualAgentHintSet) {
item := hints.Tools["sample.search_items"]
item.AvoidWhen = append([]string(nil), item.UseWhen...)
hints.Tools["sample.search_items"] = item
},
wantErr: "same literal positive and negative",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
bound := manualAgentSelectionBoundFixture()
hints := manualAgentHintSetFixture()
test.mutate(&bound, &hints)
_, _, err := BuildManualAgentSelectionEvalFixture(bound, hints)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
}
})
}
}
func TestBuildManualAgentSelectionEvalFixtureRejectsOneScenarioWithTwoExpectedTools(t *testing.T) {
bound := manualAgentSelectionBoundFixture()
root := bound.ByCanonical["sample.search_items"].PrimaryCommand.Root()
create := &cobra.Command{Use: "create", RunE: func(*cobra.Command, []string) error { return nil }}
exactSchemaCommand(root, "sample item").AddCommand(create)
createSpec := BoundCommandSpec{
CommandSpec: CommandSpec{
CanonicalPath: "sample.create_item",
PrimaryCLIPath: "sample item create",
},
PrimaryCommand: create,
}
bound.Commands = append(bound.Commands, createSpec)
bound.ByCanonical[createSpec.CanonicalPath] = createSpec
bound.ByCLIPath[createSpec.PrimaryCLIPath] = createSpec
hints := manualAgentHintSetFixture()
search := hints.Tools["sample.search_items"]
createHint := search
createHint.AgentSummary = "Create a sample item"
createHint.Examples = []string{"dws sample item create"}
createHint.UseWhen = append([]string(nil), search.UseWhen...)
createHint.AvoidWhen = []string{"An existing sample item must be found"}
hints.Tools[createSpec.CanonicalPath] = createHint
_, _, err := BuildManualAgentSelectionEvalFixture(bound, hints)
if err == nil || !strings.Contains(err.Error(), "conflicting literal expectations") {
t.Fatalf("error = %v, want literal expectation conflict", err)
}
}
func manualAgentSelectionBoundFixture() BoundCommandRegistry {
_, leaf := manualSchemaHintTestTree()
spec := BoundCommandSpec{
CommandSpec: CommandSpec{
CanonicalPath: "sample.search_items",
PrimaryCLIPath: "sample item search",
},
PrimaryCommand: leaf,
}
return BoundCommandRegistry{
Commands: []BoundCommandSpec{spec},
ByCanonical: map[string]BoundCommandSpec{spec.CanonicalPath: spec},
ByCLIPath: map[string]BoundCommandSpec{spec.PrimaryCLIPath: spec},
}
}
func TestCrossPlatformCoverageContractFinalSelectionHintWithoutDeclaredSelection(t *testing.T) {
// No ContractFinal at all: the synthesized hint stays empty but reviewed.
hint := contractFinalSelectionHint(&cobra.Command{Use: "bare"})
if !hint.Reviewed || hint.Revision != "contract" {
t.Fatalf("undeclared hint = %#v", hint)
}
if hint.AgentSummary != "" || hint.UseWhen != nil || hint.Examples != nil {
t.Fatalf("undeclared hint must carry no selection prose: %#v", hint)
}
// ContractFinal without Selection: same early return.
cmd := &cobra.Command{Use: "declared"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
RegisterRuntimeContractFinal(cmd, ContractFinalPayload{Title: "T"})
hint = contractFinalSelectionHint(cmd)
if !hint.Reviewed || hint.AgentSummary != "" || hint.UseWhen != nil {
t.Fatalf("selection-less declared hint = %#v", hint)
}
}