|
4 | 4 | "fmt" |
5 | 5 | "reflect" |
6 | 6 | "testing" |
| 7 | + |
| 8 | + "github.com/gastownhall/gascity/internal/fsys" |
7 | 9 | ) |
8 | 10 |
|
9 | 11 | // --- helper lookPath functions --- |
@@ -862,3 +864,279 @@ func TestMergeProviderOverBuiltinFieldSync(t *testing.T) { |
862 | 864 | } |
863 | 865 | } |
864 | 866 | } |
| 867 | + |
| 868 | +// TestOptionDefaultsTOMLThroughResolve exercises the full path: |
| 869 | +// TOML config → LoadWithIncludes (parses + applies patches) → ResolveProvider → EffectiveDefaults. |
| 870 | +// |
| 871 | +// Three merge layers are verified: |
| 872 | +// |
| 873 | +// Layer 1: schema-declared default (permission_mode → "plan") |
| 874 | +// Layer 2: provider-level option_defaults (model → "sonnet", overriding schema "opus") |
| 875 | +// Layer 3: agent-level option_defaults (permission_mode → "unrestricted", model → "haiku" via patch) |
| 876 | +func TestOptionDefaultsTOMLThroughResolve(t *testing.T) { |
| 877 | + fs := fsys.NewFake() |
| 878 | + |
| 879 | + // city.toml: custom provider with options_schema + option_defaults, |
| 880 | + // an agent with its own option_defaults, and a patch that adds more. |
| 881 | + fs.Files["/city/city.toml"] = []byte(` |
| 882 | +include = ["overrides.toml"] |
| 883 | +
|
| 884 | +[workspace] |
| 885 | +name = "test" |
| 886 | +
|
| 887 | +[providers.testprov] |
| 888 | +command = "testprov" |
| 889 | +prompt_mode = "arg" |
| 890 | +
|
| 891 | +[[providers.testprov.options_schema]] |
| 892 | +key = "model" |
| 893 | +label = "Model" |
| 894 | +type = "select" |
| 895 | +default = "opus" |
| 896 | +
|
| 897 | + [[providers.testprov.options_schema.choices]] |
| 898 | + value = "opus" |
| 899 | + label = "Opus" |
| 900 | + flag_args = ["--model", "opus"] |
| 901 | +
|
| 902 | + [[providers.testprov.options_schema.choices]] |
| 903 | + value = "sonnet" |
| 904 | + label = "Sonnet" |
| 905 | + flag_args = ["--model", "sonnet"] |
| 906 | +
|
| 907 | + [[providers.testprov.options_schema.choices]] |
| 908 | + value = "haiku" |
| 909 | + label = "Haiku" |
| 910 | + flag_args = ["--model", "haiku"] |
| 911 | +
|
| 912 | +[[providers.testprov.options_schema]] |
| 913 | +key = "permission_mode" |
| 914 | +label = "Permission Mode" |
| 915 | +type = "select" |
| 916 | +default = "plan" |
| 917 | +
|
| 918 | + [[providers.testprov.options_schema.choices]] |
| 919 | + value = "plan" |
| 920 | + label = "Plan" |
| 921 | + flag_args = ["--permission-mode", "plan"] |
| 922 | +
|
| 923 | + [[providers.testprov.options_schema.choices]] |
| 924 | + value = "unrestricted" |
| 925 | + label = "Unrestricted" |
| 926 | + flag_args = ["--dangerously-skip-permissions"] |
| 927 | +
|
| 928 | +[[providers.testprov.options_schema]] |
| 929 | +key = "output_format" |
| 930 | +label = "Output Format" |
| 931 | +type = "select" |
| 932 | +default = "text" |
| 933 | +
|
| 934 | + [[providers.testprov.options_schema.choices]] |
| 935 | + value = "text" |
| 936 | + label = "Text" |
| 937 | + flag_args = ["--output", "text"] |
| 938 | +
|
| 939 | + [[providers.testprov.options_schema.choices]] |
| 940 | + value = "json" |
| 941 | + label = "JSON" |
| 942 | + flag_args = ["--output", "json"] |
| 943 | +
|
| 944 | +# Provider-level overrides: model "sonnet" (instead of schema "opus"), |
| 945 | +# output_format "json" (instead of schema "text"). |
| 946 | +# output_format is provider-only — no agent overrides it, proving the |
| 947 | +# provider layer independently participates in the merge. |
| 948 | +[providers.testprov.option_defaults] |
| 949 | +model = "sonnet" |
| 950 | +output_format = "json" |
| 951 | +
|
| 952 | +[[agent]] |
| 953 | +name = "worker" |
| 954 | +provider = "testprov" |
| 955 | +
|
| 956 | +# Agent-level overrides: permission_mode and model. |
| 957 | +# model = "sonnet" here will be overwritten by the patch (model = "haiku"), |
| 958 | +# proving patch-wins-over-agent overwrite semantics (not just additive insertion). |
| 959 | +[agent.option_defaults] |
| 960 | +permission_mode = "unrestricted" |
| 961 | +model = "sonnet" |
| 962 | +`) |
| 963 | + |
| 964 | + // Patch fragment: override agent's model to "haiku". |
| 965 | + fs.Files["/city/overrides.toml"] = []byte(` |
| 966 | +[[patches.agent]] |
| 967 | +name = "worker" |
| 968 | +
|
| 969 | +[patches.agent.option_defaults] |
| 970 | +model = "haiku" |
| 971 | +`) |
| 972 | + |
| 973 | + cfg, _, err := LoadWithIncludes(fs, "/city/city.toml") |
| 974 | + if err != nil { |
| 975 | + t.Fatalf("LoadWithIncludes: %v", err) |
| 976 | + } |
| 977 | + |
| 978 | + // Find the worker agent. |
| 979 | + var worker *Agent |
| 980 | + for i := range cfg.Agents { |
| 981 | + if cfg.Agents[i].Name == "worker" { |
| 982 | + worker = &cfg.Agents[i] |
| 983 | + break |
| 984 | + } |
| 985 | + } |
| 986 | + if worker == nil { |
| 987 | + t.Fatal("worker agent not found in loaded config") |
| 988 | + } |
| 989 | + |
| 990 | + // After patching, agent.OptionDefaults should have both keys. |
| 991 | + if got := worker.OptionDefaults["permission_mode"]; got != "unrestricted" { |
| 992 | + t.Errorf("after patch: agent.OptionDefaults[permission_mode] = %q, want %q", got, "unrestricted") |
| 993 | + } |
| 994 | + if got := worker.OptionDefaults["model"]; got != "haiku" { |
| 995 | + t.Errorf("after patch: agent.OptionDefaults[model] = %q, want %q", got, "haiku") |
| 996 | + } |
| 997 | + |
| 998 | + // Resolve the provider — this merges all three layers into EffectiveDefaults. |
| 999 | + rp, err := ResolveProvider(worker, &cfg.Workspace, cfg.Providers, lookPathOnly("testprov")) |
| 1000 | + if err != nil { |
| 1001 | + t.Fatalf("ResolveProvider: %v", err) |
| 1002 | + } |
| 1003 | + |
| 1004 | + // Layer 1 (schema default "opus") overridden by Layer 2 (provider "sonnet"), |
| 1005 | + // then overridden by Layer 3 (agent "haiku" via patch). |
| 1006 | + // This also proves overwrite semantics: agent inline had model = "sonnet", |
| 1007 | + // but the patch overwrites it to "haiku". |
| 1008 | + if got := rp.EffectiveDefaults["model"]; got != "haiku" { |
| 1009 | + t.Errorf("EffectiveDefaults[model] = %q, want %q (agent patch should override agent inline and provider default)", got, "haiku") |
| 1010 | + } |
| 1011 | + |
| 1012 | + // Layer 1 (schema default "plan") overridden by Layer 3 (agent "unrestricted"). |
| 1013 | + if got := rp.EffectiveDefaults["permission_mode"]; got != "unrestricted" { |
| 1014 | + t.Errorf("EffectiveDefaults[permission_mode] = %q, want %q (agent default should override schema default)", got, "unrestricted") |
| 1015 | + } |
| 1016 | + |
| 1017 | + // Layer 2 (provider "json") is NOT overridden by any agent-level source. |
| 1018 | + // This proves the provider layer independently participates in the merge — |
| 1019 | + // without it, output_format would remain at schema default "text". |
| 1020 | + if got := rp.EffectiveDefaults["output_format"]; got != "json" { |
| 1021 | + t.Errorf("EffectiveDefaults[output_format] = %q, want %q (provider default should override schema default)", got, "json") |
| 1022 | + } |
| 1023 | +} |
| 1024 | + |
| 1025 | +// TestOptionDefaultsRigOverrideThroughResolve exercises the rig-level override |
| 1026 | +// path: TOML config → LoadWithIncludes (which internally calls ExpandPacks, |
| 1027 | +// applying AgentOverride) → ResolveProvider → EffectiveDefaults. |
| 1028 | +// |
| 1029 | +// This complements TestOptionDefaultsTOMLThroughResolve which tests the patch path. |
| 1030 | +// The rig override path is a separate code flow through applyAgentOverride (pack.go). |
| 1031 | +func TestOptionDefaultsRigOverrideThroughResolve(t *testing.T) { |
| 1032 | + fs := fsys.NewFake() |
| 1033 | + |
| 1034 | + // Pack defines an agent with no option_defaults. |
| 1035 | + fs.Files["/city/packs/svc/pack.toml"] = []byte(`[pack] |
| 1036 | +name = "svc" |
| 1037 | +schema = 1 |
| 1038 | +
|
| 1039 | +[[agent]] |
| 1040 | +name = "coder" |
| 1041 | +provider = "testprov" |
| 1042 | +`) |
| 1043 | + |
| 1044 | + // city.toml: provider with options_schema + rig with override option_defaults. |
| 1045 | + // No provider-level option_defaults — only schema defaults + agent overrides. |
| 1046 | + fs.Files["/city/city.toml"] = []byte(` |
| 1047 | +[workspace] |
| 1048 | +name = "test" |
| 1049 | +
|
| 1050 | +[providers.testprov] |
| 1051 | +command = "testprov" |
| 1052 | +prompt_mode = "arg" |
| 1053 | +
|
| 1054 | +[[providers.testprov.options_schema]] |
| 1055 | +key = "model" |
| 1056 | +label = "Model" |
| 1057 | +type = "select" |
| 1058 | +default = "opus" |
| 1059 | +
|
| 1060 | + [[providers.testprov.options_schema.choices]] |
| 1061 | + value = "opus" |
| 1062 | + label = "Opus" |
| 1063 | + flag_args = ["--model", "opus"] |
| 1064 | +
|
| 1065 | + [[providers.testprov.options_schema.choices]] |
| 1066 | + value = "haiku" |
| 1067 | + label = "Haiku" |
| 1068 | + flag_args = ["--model", "haiku"] |
| 1069 | +
|
| 1070 | +[[providers.testprov.options_schema]] |
| 1071 | +key = "permission_mode" |
| 1072 | +label = "Permission Mode" |
| 1073 | +type = "select" |
| 1074 | +default = "plan" |
| 1075 | +
|
| 1076 | + [[providers.testprov.options_schema.choices]] |
| 1077 | + value = "plan" |
| 1078 | + label = "Plan" |
| 1079 | + flag_args = ["--permission-mode", "plan"] |
| 1080 | +
|
| 1081 | + [[providers.testprov.options_schema.choices]] |
| 1082 | + value = "unrestricted" |
| 1083 | + label = "Unrestricted" |
| 1084 | + flag_args = ["--dangerously-skip-permissions"] |
| 1085 | +
|
| 1086 | +[[rigs]] |
| 1087 | +name = "myrig" |
| 1088 | +path = "/repo" |
| 1089 | +includes = ["packs/svc"] |
| 1090 | +
|
| 1091 | +[[rigs.overrides]] |
| 1092 | +agent = "coder" |
| 1093 | +
|
| 1094 | +[rigs.overrides.option_defaults] |
| 1095 | +model = "haiku" |
| 1096 | +permission_mode = "unrestricted" |
| 1097 | +`) |
| 1098 | + |
| 1099 | + // LoadWithIncludes handles the full pipeline: parse TOML → apply patches → |
| 1100 | + // ExpandPacks (which applies rig overrides). No separate ExpandPacks call needed. |
| 1101 | + cfg, _, err := LoadWithIncludes(fs, "/city/city.toml") |
| 1102 | + if err != nil { |
| 1103 | + t.Fatalf("LoadWithIncludes: %v", err) |
| 1104 | + } |
| 1105 | + |
| 1106 | + // Find the expanded agent — verify exactly one exists (LoadWithIncludes |
| 1107 | + // already expanded packs; a duplicate would indicate double expansion). |
| 1108 | + var coder *Agent |
| 1109 | + coderCount := 0 |
| 1110 | + for i := range cfg.Agents { |
| 1111 | + if cfg.Agents[i].Name == "coder" { |
| 1112 | + coder = &cfg.Agents[i] |
| 1113 | + coderCount++ |
| 1114 | + } |
| 1115 | + } |
| 1116 | + if coder == nil { |
| 1117 | + t.Fatal("coder agent not found after expansion") |
| 1118 | + } |
| 1119 | + if coderCount != 1 { |
| 1120 | + t.Fatalf("expected exactly 1 coder agent, got %d (double expansion?)", coderCount) |
| 1121 | + } |
| 1122 | + |
| 1123 | + // Override should have set agent.OptionDefaults. |
| 1124 | + if got := coder.OptionDefaults["model"]; got != "haiku" { |
| 1125 | + t.Errorf("after override: agent.OptionDefaults[model] = %q, want %q", got, "haiku") |
| 1126 | + } |
| 1127 | + |
| 1128 | + // Resolve: no provider option_defaults, so only schema defaults + agent overrides. |
| 1129 | + rp, err := ResolveProvider(coder, &cfg.Workspace, cfg.Providers, lookPathOnly("testprov")) |
| 1130 | + if err != nil { |
| 1131 | + t.Fatalf("ResolveProvider: %v", err) |
| 1132 | + } |
| 1133 | + |
| 1134 | + // Schema default "opus" overridden by agent override "haiku". |
| 1135 | + if got := rp.EffectiveDefaults["model"]; got != "haiku" { |
| 1136 | + t.Errorf("EffectiveDefaults[model] = %q, want %q", got, "haiku") |
| 1137 | + } |
| 1138 | + // Schema default "plan" overridden by agent override "unrestricted". |
| 1139 | + if got := rp.EffectiveDefaults["permission_mode"]; got != "unrestricted" { |
| 1140 | + t.Errorf("EffectiveDefaults[permission_mode] = %q, want %q", got, "unrestricted") |
| 1141 | + } |
| 1142 | +} |
0 commit comments