From eebdf52da932b57077f7e24dbc5a4131a16865b3 Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 5 Aug 2026 12:28:46 +0800 Subject: [PATCH] fix: classify local whiteboard example precondition --- internal/cli/schema_agent_examples.go | 27 ++++------- internal/cli/schema_agent_selection.go | 1 + ..._cross_platform_coverage_remaining_test.go | 27 ++++------- internal/cli/schema_runtime_registry.go | 4 ++ internal/corecmd/contract/types.go | 48 +++++++++++++++++++ .../corecmd/contract/types_coverage_test.go | 19 +++++++- internal/helpers/whiteboard.go | 8 ++++ 7 files changed, 96 insertions(+), 38 deletions(-) diff --git a/internal/cli/schema_agent_examples.go b/internal/cli/schema_agent_examples.go index c5bca55ff..52b15f4bc 100644 --- a/internal/cli/schema_agent_examples.go +++ b/internal/cli/schema_agent_examples.go @@ -30,38 +30,29 @@ import ( // remains a precise reviewed exception for such a capability whose runtime // preconditions cannot be exercised safely and deterministically in the // isolated test process. -type AgentExampleMode string +type AgentExampleMode = contract.ExampleDispositionMode const ( - AgentExampleModeContract AgentExampleMode = "contract" - AgentExampleModeDryRun AgentExampleMode = "dry_run" - AgentExampleModeContractOnly AgentExampleMode = "contract_only" + AgentExampleModeContract = contract.ExampleDispositionModeContract + AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun + AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly ) // AgentExampleReasonCode is a closed taxonomy for reviewed contract-only // exceptions to an explicit dry-run capability. -type AgentExampleReasonCode string +type AgentExampleReasonCode = contract.ExampleDispositionReasonCode const ( - AgentExampleReasonLocalState AgentExampleReasonCode = "local_state" - AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight" + AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState + AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight ) // AgentExampleDisposition narrows one exact example with an explicit // typed dry-run capability to contract-only. Index is a pointer so a missing // field cannot silently select example zero. // -// Dispositions are authored as an in-test / future ContractFinal extension -// surface; production ContractFinal Selection currently does not declare them, -// so the delivery plan treats every example as default-typed (contract or -// dry_run from ToolSpec.DryRun). -type AgentExampleDisposition struct { - Index *int `json:"index"` - Mode AgentExampleMode `json:"mode"` - ReasonCode AgentExampleReasonCode `json:"reason_code"` - Reason string `json:"reason"` - Reviewed bool `json:"reviewed"` -} +// Dispositions are authored on the owning ContractFinal Selection. +type AgentExampleDisposition = contract.ExampleDisposition // AgentExampleExecution is one resolved example and its effective test mode. type AgentExampleExecution struct { diff --git a/internal/cli/schema_agent_selection.go b/internal/cli/schema_agent_selection.go index f5f763ffa..4f7d60a98 100644 --- a/internal/cli/schema_agent_selection.go +++ b/internal/cli/schema_agent_selection.go @@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection { out.UseWhen = selection.UseWhen out.AvoidWhen = selection.AvoidWhen out.Examples = selection.Examples + out.ExampleDispositions = selection.ExampleDispositions return out } diff --git a/internal/cli/schema_cross_platform_coverage_remaining_test.go b/internal/cli/schema_cross_platform_coverage_remaining_test.go index 80b16a213..93318fb3c 100644 --- a/internal/cli/schema_cross_platform_coverage_remaining_test.go +++ b/internal/cli/schema_cross_platform_coverage_remaining_test.go @@ -123,16 +123,11 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) { t.Run("disposition narrows dry_run capability", func(t *testing.T) { bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) { payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"} - }) - t.Cleanup(restoreSelection) - agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection { - selection := contractFinalToolSelection(cmd) - selection.ExampleDispositions = []AgentExampleDisposition{{ - Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true, - Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight, + payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{ + Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true, + Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight, }} - return selection - } + }) plan, err := BuildAgentExampleExecutionPlan(bound, registry) if err != nil { t.Fatalf("plan error = %v", err) @@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) { }) t.Run("disposition without dry_run capability fails", func(t *testing.T) { - bound, registry := crossPlatformAgentExampleFixture(t, nil) - t.Cleanup(restoreSelection) - agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection { - selection := contractFinalToolSelection(cmd) - selection.ExampleDispositions = []AgentExampleDisposition{{ - Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true, - Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState, + bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) { + payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{ + Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true, + Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState, }} - return selection - } + }) _, err := BuildAgentExampleExecutionPlan(bound, registry) if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") { t.Fatalf("error = %v", err) diff --git a/internal/cli/schema_runtime_registry.go b/internal/cli/schema_runtime_registry.go index 77077da5b..1634d257c 100644 --- a/internal/cli/schema_runtime_registry.go +++ b/internal/cli/schema_runtime_registry.go @@ -332,6 +332,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C reviewed := true selection.Reviewed = &reviewed } + // Example dispositions control only the policy gate's execution eligibility. + // They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not + // part of the public ToolSpec / Schema wire contract. + selection.ExampleDispositions = nil provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun) diff --git a/internal/corecmd/contract/types.go b/internal/corecmd/contract/types.go index b01da0c58..f78aa28cf 100644 --- a/internal/corecmd/contract/types.go +++ b/internal/corecmd/contract/types.go @@ -200,6 +200,10 @@ type SelectionSpec struct { Tips []string WorkflowRefs []string Examples []string + // ExampleDispositions narrows an exact example with a reviewed local or + // stateful precondition from dry-run execution to contract validation. + // It does not change the command's declared DryRun capability. + ExampleDispositions []ExampleDisposition // Reviewed is a legacy-path (hints/registry) marker only. The Contract // declaration path must not set it: declared selection is final by // construction, and assembly rejects a declared payload carrying it. @@ -219,10 +223,54 @@ func (s SelectionSpec) Normalized() SelectionSpec { out.Tips = stableUniqueStrings(s.Tips) out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs) out.Examples = stableUniqueStrings(s.Examples) + out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions) out.SourceRefs = sortedUniqueStrings(s.SourceRefs) return out } +// ExampleDispositionMode controls how an already contract-validated example +// is exercised by the Agent example gate. +type ExampleDispositionMode string + +const ( + ExampleDispositionModeContract ExampleDispositionMode = "contract" + ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run" + ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only" +) + +// ExampleDispositionReasonCode is the closed taxonomy for reviewed +// contract-only exceptions to an explicit dry-run capability. +type ExampleDispositionReasonCode string + +const ( + ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state" + ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight" +) + +// ExampleDisposition narrows one exact example to contract-only validation. +// Index is a pointer so a missing index cannot silently select example zero. +type ExampleDisposition struct { + Index *int `json:"index"` + Mode ExampleDispositionMode `json:"mode"` + ReasonCode ExampleDispositionReasonCode `json:"reason_code"` + Reason string `json:"reason"` + Reviewed bool `json:"reviewed"` +} + +func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition { + if len(in) == 0 { + return nil + } + out := append([]ExampleDisposition(nil), in...) + for i := range out { + if out[i].Index != nil { + index := *out[i].Index + out[i].Index = &index + } + } + return out +} + // ParamDecl is one parameter-level Schema fact declared on a command. It is // stored at DeclareLeafMetadata time and applied as annotations at assembly // time, when all flags are guaranteed to exist on the fully-built command tree. diff --git a/internal/corecmd/contract/types_coverage_test.go b/internal/corecmd/contract/types_coverage_test.go index 571620649..ebb9b1b6d 100644 --- a/internal/corecmd/contract/types_coverage_test.go +++ b/internal/corecmd/contract/types_coverage_test.go @@ -75,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing } func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) { + exampleIndex := 0 normalized := (SelectionSpec{ - UseWhen: []string{" one ", "one", ""}, - AvoidWhen: []string{"avoid"}, + UseWhen: []string{" one ", "one", ""}, + AvoidWhen: []string{"avoid"}, + ExampleDispositions: []ExampleDisposition{{ + Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly, + ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true, + }}, SourceRefs: []string{"b", "a", "b"}, }).Normalized() if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" { @@ -86,6 +91,16 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" { t.Fatalf("SourceRefs = %#v", normalized.SourceRefs) } + if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 { + t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions) + } + exampleIndex = 1 + if *normalized.ExampleDispositions[0].Index != 0 { + t.Fatal("ExampleDispositions index was not cloned") + } + if got := cloneExampleDispositions(nil); got != nil { + t.Fatalf("cloneExampleDispositions(nil) = %#v", got) + } if got := stableUniqueStrings(nil); got != nil { t.Fatalf("stableUniqueStrings(nil) = %#v", got) } diff --git a/internal/helpers/whiteboard.go b/internal/helpers/whiteboard.go index 1bde45b4a..9ecdc3888 100644 --- a/internal/helpers/whiteboard.go +++ b/internal/helpers/whiteboard.go @@ -143,6 +143,7 @@ overwrite=true 表示整页重建。两种模式都会写入远端白板,必 updateCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)") updateCmd.Flags().String("source", "", "OpenNodes V1 更新请求 JSON 文件(必填)") updateCmd.Flags().Bool("yes", false, "确认写入远端白板") + updateExampleIndex := 0 DeclareLeafMetadata(updateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "high", @@ -168,6 +169,13 @@ overwrite=true 表示整页重建。两种模式都会写入远端白板,必 UseWhen: []string{"已有 nodeId、partId 和合规 OpenNodes V1 文件,用户确认后要追加图形、文本、连接线或整页替换时"}, AvoidWhen: []string{"只读取内容用 whiteboard query;创建白板卡片用 doc whiteboard insert;不要用真实节点 ID 做局部修改"}, Examples: []string{"dws whiteboard update --node --part-id --source ./whiteboard.json --format json"}, + ExampleDispositions: []contract.ExampleDisposition{{ + Index: &updateExampleIndex, + Mode: contract.ExampleDispositionModeContractOnly, + ReasonCode: contract.ExampleDispositionReasonLocalState, + Reason: "运行时需要用户提供可读且通过 OpenNodes V1 校验的本地 JSON 文件", + Reviewed: true, + }}, }, Parameters: []contract.ParamDecl{ {Name: "node", Property: "nodeId", Required: boolPtr(true)},