Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,7 @@ public class FirstPartyToolSchemaMetadataTests
public void FirstPartySchemaProperties_WhenLoaded_ShouldNotExposeDescriptionAttributes()
{
// Tests that long-form agent guidance stays in skill files instead of runtime schema metadata.
Type[] schemaTypes = TypeCache.GetTypesDerivedFrom<UnityCliLoopToolSchema>()
.Where(type => type.Assembly.GetName().Name.StartsWith(
"UnityCLILoop.FirstPartyTools",
StringComparison.Ordinal))
.ToArray();
Type[] schemaTypes = FirstPartySchemaTypes();

Assert.That(schemaTypes, Is.Not.Empty);

Expand All @@ -45,6 +41,69 @@ public void FirstPartySchemaProperties_WhenLoaded_ShouldNotExposeDescriptionAttr
}
}

[Test]
public void FirstPartySchemaEnumProperties_WhenLoaded_ShouldBeZeroBasedAndContiguous()
{
// Tests that every enum a first-party schema exposes can be resolved by its ordinal.
// The schema cache stores an enum default as a number while listing the members by name,
// so the CLI recovers the name shown in `--help` by indexing the name list with that
// number. A member with an explicit value or a [Flags] enum would make the CLI print a
// different member's name as the default.
Type[] schemaTypes = FirstPartySchemaTypes();

Assert.That(schemaTypes, Is.Not.Empty);

int checkedEnumPropertyCount = 0;
foreach (Type schemaType in schemaTypes)
{
PropertyInfo[] properties = schemaType.GetProperties(
BindingFlags.Instance |
BindingFlags.Public |
BindingFlags.DeclaredOnly);

foreach (PropertyInfo property in properties)
{
Type propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
if (!propertyType.IsEnum)
{
continue;
}

string location = $"{schemaType.FullName}.{property.Name} ({propertyType.FullName})";

Assert.That(
propertyType.GetCustomAttribute<FlagsAttribute>(),
Is.Null,
$"{location} is a [Flags] enum, which cannot be resolved by ordinal");

Array members = Enum.GetValues(propertyType);
for (int index = 0; index < members.Length; index++)
{
long value = Convert.ToInt64(members.GetValue(index));
Assert.That(
value,
Is.EqualTo((long)index),
$"{location} is not zero-based and contiguous at index {index}");
}

checkedEnumPropertyCount++;
}
}

// Guards the guard: if schemas stop exposing enums the assertions above go unreached,
// and this test would keep passing while checking nothing.
Assert.That(checkedEnumPropertyCount, Is.GreaterThan(0));
}

private static Type[] FirstPartySchemaTypes()
{
return TypeCache.GetTypesDerivedFrom<UnityCliLoopToolSchema>()
.Where(type => type.Assembly.GetName().Name.StartsWith(
"UnityCLILoop.FirstPartyTools",
StringComparison.Ordinal))
.ToArray();
}

[Test]
public void ExecuteDynamicCodeSchema_WhenCreated_ShouldNotWaitForDomainReloadByDefault()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,11 @@ private static string GetDescription(PropertyInfo property)
if (descAttr != null)
return descAttr.Description;

// Generate default description
return $"Parameter: {property.Name}";
// Generate default description. Built from the JSON property name, not the C# one,
// because the CLI recognizes this placeholder by comparing it against the JSON name it
// received: a [JsonProperty] rename would otherwise make the placeholder unrecognizable
// and leave it in --help output as if it were a real description.
return $"Parameter: {GetJsonPropertyName(property)}";
}

/// <summary>
Expand Down
4 changes: 4 additions & 0 deletions cli/common/clicore/tool_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ func LoadDefaultTools() ToolsCache {
return tools.LoadDefault()
}

func ApplyEmbeddedDescriptionFallback(cache ToolsCache) ToolsCache {
return tools.ApplyEmbeddedDescriptionFallback(cache)
}

func FindTool(cache ToolsCache, name string) (ToolDefinition, bool) {
return tools.Find(cache, name)
}
Expand Down
35 changes: 35 additions & 0 deletions cli/common/tooldocs/enum_defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package tooldocs

// EnumValueForNumericDefault converts a numeric default on an enum property into the member name
// that has to be typed on the command line. Unity's schema generator serializes a C# enum default
// as its ordinal, so both option listings would otherwise report "default: 0" for a parameter that
// only accepts names such as "Press".
//
// The conversion assumes the enum is zero-based and contiguous, which is what a name lookup by
// ordinal requires. A value outside the listed range yields no conversion so the raw number is
// shown instead of a wrong member name.
func EnumValueForNumericDefault(defaultValue any, values []string) (string, bool) {
if len(values) == 0 || defaultValue == nil {
return "", false
}

switch value := defaultValue.(type) {
case int:
return enumValueAtIndex(value, values)
case float64:
index := int(value)
if value != float64(index) {
return "", false
}
return enumValueAtIndex(index, values)
default:
return "", false
}
}

func enumValueAtIndex(index int, values []string) (string, bool) {
if index < 0 || index >= len(values) {
return "", false
}
return values[index], true
}
71 changes: 71 additions & 0 deletions cli/common/tooldocs/enum_defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package tooldocs

import (
"strings"
"testing"

"github.com/hatayama/unity-cli-loop/common/tools"
)

// Verifies an enum property whose cached default is the C# enum's ordinal renders the member name
// in --help. Unity's schema cache serializes enum defaults as numbers, so --help showed
// "default: 0" for a value that has to be passed as "Press".
func TestOptionDescriptionRendersEnumDefaultByName(t *testing.T) {
property := tools.ToolProperty{
Type: "string",
Description: "Keyboard action",
DefaultValue: float64(0),
Enum: []string{"Press", "KeyDown", "KeyUp", "ReleaseAll"},
}

description := optionDescription("simulate-keyboard", "Action", property)
if !strings.Contains(description, "default: Press") {
t.Errorf("description = %q, want it to contain %q", description, "default: Press")
}
}

// Verifies a string default that already names an enum member is passed through untouched.
func TestOptionDescriptionKeepsNamedEnumDefault(t *testing.T) {
property := tools.ToolProperty{
Type: "string",
Description: "Pause point mode",
DefaultValue: "single-shot",
Enum: []string{"single-shot", "repeat"},
}

description := optionDescription("enable-pause-point", "Mode", property)
if !strings.Contains(description, "default: single-shot") {
t.Errorf("description = %q, want it to contain %q", description, "default: single-shot")
}
}

// Verifies a numeric default on a property with no enum stays numeric, so --timeout-seconds does
// not get mistaken for an enum ordinal.
func TestOptionDescriptionKeepsNumericDefaultWithoutEnum(t *testing.T) {
property := tools.ToolProperty{
Type: "number",
Description: "Timeout",
DefaultValue: float64(30),
Enum: nil,
}

description := optionDescription("await-pause-point", "TimeoutSeconds", property)
if !strings.Contains(description, "default: 30") {
t.Errorf("description = %q, want it to contain %q", description, "default: 30")
}
}

// Verifies an ordinal outside the enum's range is left as-is rather than reported as some other
// member: guessing a name there would be worse than showing the raw value.
func TestEnumValueForNumericDefaultRejectsOutOfRangeOrdinal(t *testing.T) {
if value, ok := EnumValueForNumericDefault(float64(7), []string{"Press", "KeyDown"}); ok {
t.Errorf("out-of-range ordinal resolved to %q, want no conversion", value)
}
}

// Verifies a fractional default is not treated as an ordinal, since no enum member can match it.
func TestEnumValueForNumericDefaultRejectsFractionalOrdinal(t *testing.T) {
if value, ok := EnumValueForNumericDefault(0.5, []string{"Press", "KeyDown"}); ok {
t.Errorf("fractional ordinal resolved to %q, want no conversion", value)
}
}
130 changes: 130 additions & 0 deletions cli/common/tooldocs/pause_point_cli_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package tooldocs

import "strings"

// enable-pause-point accepts six orchestration flags that exist only in the CLI: they are parsed
// out of the argv before the Unity-side EnablePausePointSchema is consulted, so nothing in the
// tool schema describes them. Both listings that document a tool's options — the dispatcher's
// `--help` table and the project runner's `uloop list` output — therefore have to add them by
// hand, and they drifted: `uloop list` documented all six while `--help` documented none.
// Defining them once here makes both listings read the same table.
const (
PausePointEnableAwaitFlagName = "await"
PausePointCapturedVariablesFlagName = "captured-variables"
PausePointCapturedVariableNamesFlagName = "captured-variable-names"
PausePointExpectFlagName = "expect"
PausePointTriggerFlagName = "trigger"
PausePointResumePlayFlagName = "resume-play"
)

// Accepted --captured-variables values. Declared here because they appear in the option listings;
// the runner's own mode type is defined from these constants so the two cannot drift.
const (
PausePointCapturedVariablesModeFull = "full"
PausePointCapturedVariablesModeNames = "names"
)

// pausePointEnableCommandName is private to this package: importing the clicore package that owns
// the command-name constants would be an import cycle, the same reason
// executeDynamicCodeCommandName is declared locally.
const pausePointEnableCommandName = "enable-pause-point"

// PausePointCLIOnlyOption describes one CLI-only pause-point flag in the shape both listings need:
// `--help` renders FlagName/Type/Description, and `uloop list` additionally reports Type and Values
// as structured fields.
type PausePointCLIOnlyOption struct {
FlagName string
Type string
Description string
Values []string
}

// PausePointEnableCLIOnlyOptions returns enable-pause-point's CLI-only flags. A fresh slice is
// built per call so a caller that sorts or appends cannot mutate the shared table.
func PausePointEnableCLIOnlyOptions() []PausePointCLIOnlyOption {
return []PausePointCLIOnlyOption{
{
FlagName: PausePointEnableAwaitFlagName,
Type: "boolean",
Description: "Wait for the marker to be hit (or time out) after enabling, in a single call, " +
"instead of a separate await-pause-point call",
},
{
FlagName: PausePointCapturedVariablesFlagName,
Type: "string",
Description: "Requires --await. Same as await-pause-point's --captured-variables",
Values: []string{
PausePointCapturedVariablesModeFull,
PausePointCapturedVariablesModeNames,
},
},
{
FlagName: PausePointCapturedVariableNamesFlagName,
Type: "string",
Description: "Requires --await. Same as await-pause-point's --captured-variable-names",
},
{
FlagName: PausePointExpectFlagName,
Type: "string",
Description: "Requires --await. Same as await-pause-point's --expect (repeatable)",
},
{
FlagName: PausePointTriggerFlagName,
Type: "string",
Description: "Requires --await. Same as await-pause-point's --trigger",
},
{
FlagName: PausePointResumePlayFlagName,
Type: "boolean",
Description: "Requires --await. After confirming the marker is armed, resume PlayMode if " +
"paused (before --trigger), so a paused-arm workflow can fire input in one call",
},
}
}

func appendPausePointEnableCLIOnlyOptionHelpEntries(
toolName string,
entries []OptionHelpEntry,
) []OptionHelpEntry {
if toolName != pausePointEnableCommandName {
return entries
}

for _, option := range PausePointEnableCLIOnlyOptions() {
optionName := "--" + option.FlagName
if hasOptionHelpEntry(entries, optionName) {
continue
}
entries = append(entries, OptionHelpEntry{
Name: optionName,
Usage: pausePointCLIOnlyOptionUsage(optionName, option),
Description: pausePointCLIOnlyOptionDescription(option),
})
}
return entries
}

func pausePointCLIOnlyOptionUsage(optionName string, option PausePointCLIOnlyOption) string {
if option.Type == "boolean" {
return optionName
}
return optionName + " <value>"
}

// pausePointCLIOnlyOptionDescription appends the accepted values the same way the schema-driven
// rows do, so a CLI-only row is indistinguishable in shape from a schema-derived one.
func pausePointCLIOnlyOptionDescription(option PausePointCLIOnlyOption) string {
if len(option.Values) == 0 {
return option.Description
}
return option.Description + "; values: " + strings.Join(option.Values, optionValuesSeparator)
}

func hasOptionHelpEntry(entries []OptionHelpEntry, name string) bool {
for _, entry := range entries {
if entry.Name == name {
return true
}
}
return false
}
Loading