package configrefresh_test import ( "strings" "testing" "iop/apps/edge/internal/configrefresh" "iop/packages/go/config" ) func TestClassifyExecutionPresetLiveApply(t *testing.T) { current := &config.EdgeConfig{ ExecutionPresets: []config.ExecutionPreset{ { ID: "preset-z-remove", Selector: config.ExecutionModelBinding{Model: "gpt-4o"}, AllowedModes: []string{config.ModeDirect}, Routes: map[string]config.ExecutionRoute{ config.ModeDirect: {Stages: []config.ExecutionRouteStage{}}, }, }, { ID: "preset-m-mod", Selector: config.ExecutionModelBinding{Model: "gpt-4o", Options: map[string]any{"a": 1}}, AllowedModes: []string{config.ModeDirect}, Routes: map[string]config.ExecutionRoute{ config.ModeDirect: {Stages: []config.ExecutionRouteStage{}}, }, SingleRequest: &config.ExecutionSingleRequestPolicy{ WorkspaceRef: "ws-ref-current", Limits: config.ExecutionSingleRequestLimits{ WallClockMS: 10 * 60 * 1000, StageTimeoutMS: 5 * 60 * 1000, MaxToolIterations: 32, MaxOutputBytes: 8 * 1024 * 1024, }, Stages: config.ExecutionSingleRequestStages{ Plan: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, Work: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o-mini"}, Review: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, }, }, }, }, } // Candidate list has IDs out of lexical order (preset-a-add first, then preset-m-mod) candidate := &config.EdgeConfig{ ExecutionPresets: []config.ExecutionPreset{ { ID: "preset-a-add", Selector: config.ExecutionModelBinding{Model: "claude-3-5-sonnet"}, AllowedModes: []string{config.ModeDirect}, Routes: map[string]config.ExecutionRoute{ config.ModeDirect: {Stages: []config.ExecutionRouteStage{}}, }, }, { ID: "preset-m-mod", Selector: config.ExecutionModelBinding{Model: "gpt-4o-mini", Options: map[string]any{"a": 2}}, AllowedModes: []string{config.ModeDirect, config.ModeLight}, Routes: map[string]config.ExecutionRoute{ config.ModeDirect: {Stages: []config.ExecutionRouteStage{}}, config.ModeLight: { Stages: []config.ExecutionRouteStage{ {Role: "local", Model: "gpt-4o"}, {Role: "review", Model: "gpt-4o"}, }, }, }, SingleRequest: &config.ExecutionSingleRequestPolicy{ WorkspaceRef: "ws-ref-next", Limits: config.ExecutionSingleRequestLimits{ WallClockMS: 20 * 60 * 1000, StageTimeoutMS: 8 * 60 * 1000, MaxToolIterations: 64, MaxOutputBytes: 16 * 1024 * 1024, }, Stages: config.ExecutionSingleRequestStages{ Plan: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, Work: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o-mini"}, Review: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, }, }, WorkspaceTools: []config.ExecutionWorkspaceToolAlternative{ { Name: "default", Operations: map[string]config.ExecutionWorkspaceOperation{ "read": {ToolName: "file_read", SchemaMatcher: map[string]any{"sm": "r"}, ArgumentMap: map[string]any{"arg": "r"}, ResultMatcher: map[string]any{"res": "r"}}, "write": {ToolName: "file_write", CreatesParents: true, SchemaMatcher: map[string]any{"sm": "w"}, ArgumentMap: map[string]any{"arg": "w"}, ResultMatcher: map[string]any{"res": "w"}}, "delete": {ToolName: "file_delete", SchemaMatcher: map[string]any{"sm": "d"}, ArgumentMap: map[string]any{"arg": "d"}, ResultMatcher: map[string]any{"res": "d"}}, }, }, }, }, }, } result := configrefresh.Classify(current, candidate) if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=%q, got %q (changes: %+v)", configrefresh.StatusApplied, result.Status, result.Changes) } type expectedChange struct { path string class configrefresh.Status } want := []expectedChange{ {path: `execution_presets["preset-a-add"]`, class: configrefresh.StatusApplied}, {path: `execution_presets["preset-m-mod"].allowed_modes`, class: configrefresh.StatusApplied}, {path: `execution_presets["preset-m-mod"].routes`, class: configrefresh.StatusApplied}, {path: `execution_presets["preset-m-mod"].selector`, class: configrefresh.StatusApplied}, {path: `execution_presets["preset-m-mod"].single_request`, class: configrefresh.StatusApplied}, {path: `execution_presets["preset-m-mod"].workspace_tools`, class: configrefresh.StatusApplied}, {path: `execution_presets["preset-z-remove"]`, class: configrefresh.StatusApplied}, } if len(result.Changes) != len(want) { t.Fatalf("got %d changes, want %d (actual changes: %+v)", len(result.Changes), len(want), result.Changes) } for i, c := range result.Changes { if c.Path != want[i].path { t.Errorf("change[%d] path: got %q, want %q", i, c.Path, want[i].path) } if c.Class != want[i].class { t.Errorf("change[%d] class for %s: got %q, want %q", i, c.Path, c.Class, want[i].class) } if c.Path == `execution_presets["preset-m-mod"].single_request` { if !strings.Contains(c.Previous, "PlanDigest:") || !strings.Contains(c.Previous, "ReviewDigest:") { t.Errorf("single_request previous = %q, expected redacted template digests", c.Previous) } if !strings.Contains(c.Next, "PlanDigest:") || !strings.Contains(c.Next, "ReviewDigest:") { t.Errorf("single_request next = %q, expected redacted template digests", c.Next) } } } paths := make([]string, 0, len(result.Changes)) for _, c := range result.Changes { paths = append(paths, c.Path) } routesIdx, srIdx, wsIdx := -1, -1, -1 for i, p := range paths { switch { case p == `execution_presets["preset-m-mod"].routes`: routesIdx = i case p == `execution_presets["preset-m-mod"].single_request`: srIdx = i case p == `execution_presets["preset-m-mod"].workspace_tools`: wsIdx = i } } if routesIdx < 0 { t.Fatalf("expected a change at execution_presets[\"preset-m-mod\"].routes, got changes: %+v", result.Changes) } if srIdx < 0 { t.Fatalf("expected a change at execution_presets[\"preset-m-mod\"].single_request, got changes: %+v", result.Changes) } if wsIdx < 0 { t.Fatalf("expected a change at execution_presets[\"preset-m-mod\"].workspace_tools, got changes: %+v", result.Changes) } if routesIdx >= srIdx { t.Errorf("single_request path must appear after routes: routes@%d, single_request@%d", routesIdx, srIdx) } if srIdx >= wsIdx { t.Errorf("single_request path must appear before workspace_tools: single_request@%d, workspace_tools@%d", srIdx, wsIdx) } } // TestClassifyModelExecutionPresetLiveApply verifies that changing a virtual // model's execution_preset mapping is reported as a single live-applied change // and attributes the model in ChangedModels. func TestClassifyModelExecutionPresetLiveApply(t *testing.T) { current := &config.EdgeConfig{ Models: []config.ModelCatalogEntry{ {ID: "virtual-model", ExecutionPreset: "preset-a"}, }, } candidate := &config.EdgeConfig{ Models: []config.ModelCatalogEntry{ {ID: "virtual-model", ExecutionPreset: "preset-b"}, }, } result := configrefresh.Classify(current, candidate) if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=%q, got %q (changes: %+v)", configrefresh.StatusApplied, result.Status, result.Changes) } if result.Summary != "all changes can be applied without restart" { t.Errorf("summary = %q, want all-applied summary", result.Summary) } if len(result.Changes) != 1 { t.Fatalf("got %d changes, want 1 (actual: %+v)", len(result.Changes), result.Changes) } change := result.Changes[0] if change.Path != `models["virtual-model"].execution_preset` { t.Errorf("change path: got %q, want %q", change.Path, `models["virtual-model"].execution_preset`) } if change.Class != configrefresh.StatusApplied { t.Errorf("change class: got %q, want %q", change.Class, configrefresh.StatusApplied) } if change.Previous != "preset-a" { t.Errorf("change previous: got %q, want %q", change.Previous, "preset-a") } if change.Next != "preset-b" { t.Errorf("change next: got %q, want %q", change.Next, "preset-b") } if len(result.ChangedModels) != 1 || result.ChangedModels[0] != "virtual-model" { t.Errorf("ChangedModels = %v, want [virtual-model]", result.ChangedModels) } } // templatePath builds an explicitly configured optional template path. A nil // field means the operator omitted the file, so configured fixtures must carry // a pointer. func templatePath(path string) *string { return &path } func TestClassifySingleRequestTemplateContentChange(t *testing.T) { current := &config.EdgeConfig{ ExecutionPresets: []config.ExecutionPreset{ { ID: "preset-templates", Selector: config.ExecutionModelBinding{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, AllowedModes: []string{config.ModeLight}, Routes: map[string]config.ExecutionRoute{ config.ModeLight: { Stages: []config.ExecutionRouteStage{ {Role: "plan", Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, {Role: "work", Model: "gpt-4o-mini"}, {Role: "review", Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, }, }, }, SingleRequest: &config.ExecutionSingleRequestPolicy{ WorkspaceRef: "ws-1", Limits: config.ExecutionSingleRequestLimits{ WallClockMS: 60000, StageTimeoutMS: 30000, MaxToolIterations: 10, MaxOutputBytes: 1024, }, Stages: config.ExecutionSingleRequestStages{ Plan: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, Work: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o-mini"}, Review: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, }, Templates: config.ExecutionSingleRequestTemplates{ PlanFile: templatePath("templates/plan.md"), ReviewFile: templatePath("templates/review.md"), EffectivePlan: "Plan template v1\n# Plan\n## Goal\n{{goal}}\n## Steps\n{{steps}}\n## Verification\n{{verification}}", EffectiveReview: "Review template v1\n# Review\n## Result\nPASS\n## Checks\n{{checks}}\n## Verification\n{{verification}}\n## Summary\n{{summary}}", }, }, }, }, } candidate := &config.EdgeConfig{ ExecutionPresets: []config.ExecutionPreset{ { ID: "preset-templates", Selector: config.ExecutionModelBinding{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, AllowedModes: []string{config.ModeLight}, Routes: map[string]config.ExecutionRoute{ config.ModeLight: { Stages: []config.ExecutionRouteStage{ {Role: "plan", Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, {Role: "work", Model: "gpt-4o-mini"}, {Role: "review", Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, }, }, }, SingleRequest: &config.ExecutionSingleRequestPolicy{ WorkspaceRef: "ws-1", Limits: config.ExecutionSingleRequestLimits{ WallClockMS: 60000, StageTimeoutMS: 30000, MaxToolIterations: 10, MaxOutputBytes: 1024, }, Stages: config.ExecutionSingleRequestStages{ Plan: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, Work: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o-mini"}, Review: config.ExecutionSingleRequestStageConfig{Model: "gpt-4o", Options: map[string]any{"reasoning_effort": "high"}}, }, Templates: config.ExecutionSingleRequestTemplates{ PlanFile: templatePath("templates/plan.md"), // same path, distinct pointer ReviewFile: templatePath("templates/review.md"), EffectivePlan: "Plan template v2\n# Plan\n## Goal\n{{goal}}\n## Steps\n{{steps}}\n## Verification\n{{verification}}", // content changed EffectiveReview: "Review template v1\n# Review\n## Result\nPASS\n## Checks\n{{checks}}\n## Verification\n{{verification}}\n## Summary\n{{summary}}", }, }, }, }, } result := configrefresh.Classify(current, candidate) if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=%q, got %q (changes: %+v)", configrefresh.StatusApplied, result.Status, result.Changes) } if len(result.Changes) != 1 { t.Fatalf("expected 1 change, got %d", len(result.Changes)) } c := result.Changes[0] if c.Path != `execution_presets["preset-templates"].single_request` { t.Errorf("change path = %q, want execution_presets[\"preset-templates\"].single_request", c.Path) } if c.Class != configrefresh.StatusApplied { t.Errorf("change class = %q, want StatusApplied", c.Class) } if strings.Contains(c.Previous, "templates/plan.md") || strings.Contains(c.Next, "templates/plan.md") { t.Errorf("change output exposes file path: prev=%q, next=%q", c.Previous, c.Next) } if strings.Contains(c.Previous, "Plan template v1") || strings.Contains(c.Next, "Plan template v2") { t.Errorf("change output exposes raw template body: prev=%q, next=%q", c.Previous, c.Next) } }