iop/apps/edge/internal/configrefresh/execution_preset_classify_test.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

216 lines
8.5 KiB
Go

package configrefresh_test
import (
"fmt"
"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 c.Previous != fmt.Sprintf("%v", current.ExecutionPresets[1].SingleRequest) {
t.Errorf("single_request previous = %q, want %q", c.Previous, fmt.Sprintf("%v", current.ExecutionPresets[1].SingleRequest))
}
if c.Next != fmt.Sprintf("%v", candidate.ExecutionPresets[1].SingleRequest) {
t.Errorf("single_request next = %q, want %q", c.Next, fmt.Sprintf("%v", candidate.ExecutionPresets[1].SingleRequest))
}
}
}
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)
}
}