Files
tutti/services/tuttid/service/agent/extension_composer_create_validation_test.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

513 lines
18 KiB
Go

package agent
import (
"context"
"errors"
"slices"
"testing"
"time"
agenttargetbiz "github.com/tutti-os/tutti/services/tuttid/biz/agenttarget"
preferencesbiz "github.com/tutti-os/tutti/services/tuttid/biz/preferences"
)
const extensionComposerValidationTargetID = "extension:gemini-validation"
func TestExtensionComposerModelValidationDegradesWhileLiveCatalogDiscoveryIsPending(t *testing.T) {
runtime := newFakeRuntime()
started := make(chan struct{})
release := make(chan struct{})
closed := make(chan struct{})
refreshed := make(chan string, 1)
runtime.startHook = func(input RuntimeStartInput, session ProviderRuntimeSession) ProviderRuntimeSession {
if input.Visible != nil && !*input.Visible {
close(started)
<-release
session.RuntimeContext["configOptions"] = []any{map[string]any{
"id": "model", "options": []any{
map[string]any{"value": "gemini-pro", "name": "Gemini Pro"},
},
}}
}
return session
}
runtime.closeHook = func(RuntimeCloseInput) { close(closed) }
service := newIsolatedAgentService(runtime)
service.LiveModelCatalogUpdated = func(provider string) {
refreshed <- provider
}
service.liveModelDiscoveryWaitTimeout = 10 * time.Millisecond
input := extensionComposerDiscoveryInput(t.TempDir())
input.Settings.Model = "gemini-pro"
input.extensionComposerProfile.ModelConfigOptionID = "model"
base := ComposerOptions{
Provider: input.Provider,
EffectiveSettings: input.Settings,
ModelConfig: ComposerConfigOption{
CurrentValue: "gemini-pro",
DefaultValue: "gemini-pro",
Options: composerSelectedModelOptions("gemini-pro"),
},
RuntimeContext: map[string]any{},
}
type mergeResult struct {
options ComposerOptions
err error
}
result := make(chan mergeResult, 1)
go func() {
options, err := service.mergeLiveComposerModelsForComposerOptions(
context.Background(), input, input.Settings, base,
)
result <- mergeResult{options: options, err: err}
}()
<-started
merged := <-result
if merged.err != nil {
t.Fatalf("mergeLiveComposerModelsForComposerOptions() error = %v", merged.err)
}
if !merged.options.liveModelDiscoveryPending {
t.Fatal("live model discovery pending state was not preserved")
}
if err := validateExtensionComposerModelForCreate("gemini-pro", merged.options); err != nil {
t.Fatalf("pending live catalog rejected selected model: %v", err)
}
close(release)
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("timed out waiting for hidden discovery cleanup")
}
select {
case provider := <-refreshed:
if provider != input.Provider {
t.Fatalf("refreshed provider = %q, want %q", provider, input.Provider)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for live model catalog refresh notification")
}
}
func TestExtensionComposerModelValidationRemainsStrictWithAdvertisedCatalog(t *testing.T) {
options := ComposerOptions{
liveModelDiscoveryPending: true,
ModelConfig: ComposerConfigOption{
Configurable: true,
Options: []ComposerConfigOptionValue{
{Value: "gemini-pro"},
{Value: "gemini-fast"},
},
},
}
if err := validateExtensionComposerModelForCreate("unknown-model", options); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("advertised catalog validation error = %v, want ErrInvalidArgument", err)
}
}
func TestServiceCreateValidatesExtensionDefaultsAndExplicitOverrides(t *testing.T) {
runtime, service := newExtensionComposerValidationService(t)
service.AgentComposerDefaultsReader = fakeAgentComposerDefaultsReader{
extensionComposerValidationTargetID: {
Model: "gemini-pro",
PermissionModeID: "yolo",
},
}
explicitModel := "gemini-fast"
if _, err := service.Create(context.Background(), "workspace-extension", CreateSessionInput{
AgentTargetID: extensionComposerValidationTargetID,
Cwd: stringPointer(t.TempDir()),
Model: &explicitModel,
}); err != nil {
t.Fatalf("Create() error = %v", err)
}
visibleStarts := visibleRuntimeStarts(runtime.startCalls)
if len(visibleStarts) != 1 {
t.Fatalf("visible starts = %#v, want one", visibleStarts)
}
if visibleStarts[0].Model != "gemini-fast" || visibleStarts[0].PermissionModeID != "yolo" {
t.Fatalf("visible settings = %#v", visibleStarts[0])
}
}
func TestServiceCreateRoundTripsCodeBuddyLikeRuntimePermissionID(t *testing.T) {
runtime := newFakeRuntime()
runtime.startHook = func(input RuntimeStartInput, session ProviderRuntimeSession) ProviderRuntimeSession {
if input.Visible != nil && !*input.Visible {
session.RuntimeContext = map[string]any{
"configOptions": []any{
map[string]any{
"id": "model",
"currentValue": "glm-5.2",
"options": []any{
map[string]any{"value": "glm-5.2", "name": "GLM-5.2"},
},
},
map[string]any{
"id": "approval-mode",
"currentValue": "default",
"options": []any{
map[string]any{"value": "default", "name": "Default"},
map[string]any{"value": "bypassPermissions", "name": "Full access"},
map[string]any{"value": "fullAccess", "name": "Full access"},
},
},
},
}
}
return session
}
service := newIsolatedAgentService(runtime)
service.AgentTargetStore = fakeAgentTargetStore{targets: map[string]agenttargetbiz.Target{
"extension:codebuddy": {
ID: "extension:codebuddy",
Provider: "acp:codebuddy",
LaunchRefJSON: `{"type":"agent_extension","extensionInstallationId":"codebuddy@2.0.3"}`,
Name: "CodeBuddy",
Enabled: true,
Source: agenttargetbiz.SourceSystem,
},
}}
service.ExtensionComposerProfiles = extensionComposerProfileResolverStub{
profile: ExtensionComposerProfile{
ModelConfigOptionID: "model",
PermissionConfigOptionID: "approval-mode",
PermissionModeIDPolicy: ExtensionPermissionModeIDPolicyRuntime,
PermissionModes: []ExtensionComposerPermissionMode{
{RuntimeID: "default", Semantic: PermissionModeSemanticAskBeforeWrite},
{RuntimeID: "bypassPermissions", Semantic: PermissionModeSemanticFullAccess},
{RuntimeID: "fullAccess", Semantic: PermissionModeSemanticFullAccess},
},
},
}
cwd := t.TempDir()
settings := ComposerSettings{Model: "glm-5.2", PermissionModeID: "bypassPermissions"}
options, err := service.GetComposerOptions(context.Background(), ComposerOptionsInput{
AgentTargetID: "extension:codebuddy",
WorkspaceID: "workspace-codebuddy",
Cwd: cwd,
Settings: settings,
})
if err != nil {
t.Fatalf("GetComposerOptions() error = %v", err)
}
if options.EffectiveSettings.PermissionModeID != "bypassPermissions" {
t.Fatalf("effective permission = %q, want explicit runtime id", options.EffectiveSettings.PermissionModeID)
}
if got := permissionModeIDs(options.PermissionConfig); !slices.Equal(got, []string{"default", "bypassPermissions", "fullAccess"}) {
t.Fatalf("permission ids = %#v", got)
}
model := settings.Model
permission := settings.PermissionModeID
if _, err := service.Create(context.Background(), "workspace-codebuddy", CreateSessionInput{
AgentTargetID: "extension:codebuddy",
Cwd: &cwd,
Model: &model,
PermissionModeID: &permission,
}); err != nil {
t.Fatalf("Create() error = %v", err)
}
visibleStarts := visibleRuntimeStarts(runtime.startCalls)
if len(visibleStarts) != 1 || visibleStarts[0].PermissionModeID != "bypassPermissions" {
t.Fatalf("visible starts = %#v, want exact runtime permission id", visibleStarts)
}
for _, start := range runtime.startCalls {
if start.PermissionModeID != "bypassPermissions" {
t.Fatalf("runtime start permission = %q, want exact selected id", start.PermissionModeID)
}
}
}
func TestServiceGetComposerOptionsRejectsSemanticAliasBeforeRuntimeDiscovery(t *testing.T) {
runtime, service := newExtensionComposerValidationService(t)
_, err := service.GetComposerOptions(context.Background(), ComposerOptionsInput{
AgentTargetID: extensionComposerValidationTargetID,
WorkspaceID: "workspace-extension",
Cwd: t.TempDir(),
Settings: ComposerSettings{
PermissionModeID: "full-access",
},
})
var unsupported *UnsupportedPermissionModeIDError
if !errors.As(err, &unsupported) {
t.Fatalf("GetComposerOptions() error = %v, want UnsupportedPermissionModeIDError", err)
}
if len(runtime.startCalls) != 0 {
t.Fatalf("runtime starts = %#v, want invalid id rejected before discovery", runtime.startCalls)
}
}
func TestServiceGetComposerOptionsIgnoresStalePersistedPermissionDefault(t *testing.T) {
runtime, service := newExtensionComposerValidationService(t)
cwd := t.TempDir()
service.AgentComposerDefaultsReader = fakeAgentComposerDefaultsReader{
extensionComposerValidationTargetID: {PermissionModeID: "full-access"},
}
service.ExtensionComposerProfiles = extensionComposerProfileResolverStub{
profile: ExtensionComposerProfile{
DefaultPermissionModeID: "default",
PermissionModeIDPolicy: ExtensionPermissionModeIDPolicyRuntime,
PermissionModes: []ExtensionComposerPermissionMode{
{RuntimeID: "default", Semantic: PermissionModeSemanticAskBeforeWrite},
{RuntimeID: "bypassPermissions", Semantic: PermissionModeSemanticFullAccess},
{RuntimeID: "fullAccess", Semantic: PermissionModeSemanticFullAccess},
},
},
}
options, err := service.GetComposerOptions(context.Background(), ComposerOptionsInput{
AgentTargetID: extensionComposerValidationTargetID,
WorkspaceID: "workspace-extension",
Cwd: cwd,
})
if err != nil {
t.Fatalf("GetComposerOptions() error = %v", err)
}
if options.EffectiveSettings.PermissionModeID != "default" ||
!slices.Equal(permissionModeIDs(options.PermissionConfig), []string{"default", "bypassPermissions", "fullAccess"}) {
t.Fatalf("options = %#v, want stale default ignored and current contract advertised", options)
}
if len(runtime.startCalls) == 0 {
t.Fatal("runtime discovery did not run")
}
if _, err := service.Create(context.Background(), "workspace-extension", CreateSessionInput{
AgentTargetID: extensionComposerValidationTargetID,
Cwd: &cwd,
}); err != nil {
t.Fatalf("Create() with stale persisted default error = %v", err)
}
visibleStarts := visibleRuntimeStarts(runtime.startCalls)
if len(visibleStarts) != 1 || visibleStarts[0].PermissionModeID != "default" {
t.Fatalf("visible starts = %#v, want recovered profile default", visibleStarts)
}
}
func TestServiceGetComposerOptionsUsesRuntimeCurrentBeforeSemanticProfileDefault(t *testing.T) {
runtime, service := newExtensionComposerValidationService(t)
runtime.startHook = func(input RuntimeStartInput, session ProviderRuntimeSession) ProviderRuntimeSession {
if input.Visible != nil && !*input.Visible {
session.RuntimeContext = map[string]any{"configOptions": []any{map[string]any{
"id": "approval-mode", "currentValue": "all", "options": []any{
map[string]any{"value": "ask", "name": "Ask"},
map[string]any{"value": "all", "name": "Full access"},
},
}}}
}
return session
}
service.ExtensionComposerProfiles = extensionComposerProfileResolverStub{
profile: ExtensionComposerProfile{
DefaultPermissionModeID: "ask-before-write",
PermissionConfigOptionID: "approval-mode",
PermissionModeIDPolicy: ExtensionPermissionModeIDPolicySemantic,
PermissionModes: []ExtensionComposerPermissionMode{
{RuntimeID: "ask", Semantic: PermissionModeSemanticAskBeforeWrite},
{RuntimeID: "all", Semantic: PermissionModeSemanticFullAccess},
},
},
}
options, err := service.GetComposerOptions(context.Background(), ComposerOptionsInput{
AgentTargetID: extensionComposerValidationTargetID,
WorkspaceID: "workspace-extension",
Cwd: t.TempDir(),
})
if err != nil {
t.Fatalf("GetComposerOptions() error = %v", err)
}
if options.EffectiveSettings.PermissionModeID != "full-access" {
t.Fatalf("effective permission = %q, want runtime current", options.EffectiveSettings.PermissionModeID)
}
}
func TestServiceCreateRejectsExtensionSettingsOutsideDescriptor(t *testing.T) {
tests := []struct {
name string
defaults preferencesbiz.AgentComposerDefaults
input CreateSessionInput
}{
{
name: "model override",
input: CreateSessionInput{
Model: stringPointer("unknown-model"),
},
},
{
name: "permission override",
input: CreateSessionInput{
Model: stringPointer("gemini-pro"),
PermissionModeID: stringPointer("unknown-permission"),
},
},
{
name: "undeclared reasoning",
input: CreateSessionInput{
Model: stringPointer("gemini-pro"),
ReasoningEffort: stringPointer("high"),
},
},
{
name: "undeclared speed",
input: CreateSessionInput{
Model: stringPointer("gemini-pro"),
Speed: stringPointer("fast"),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime, service := newExtensionComposerValidationService(t)
service.AgentComposerDefaultsReader = fakeAgentComposerDefaultsReader{
extensionComposerValidationTargetID: test.defaults,
}
input := test.input
input.AgentTargetID = extensionComposerValidationTargetID
input.Cwd = stringPointer(t.TempDir())
_, err := service.Create(context.Background(), "workspace-extension", input)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("Create() error = %v, want ErrInvalidArgument", err)
}
if starts := visibleRuntimeStarts(runtime.startCalls); len(starts) != 0 {
t.Fatalf("visible starts = %#v, want none", starts)
}
})
}
}
func TestServiceCreateIgnoresRetiredExtensionModelDefault(t *testing.T) {
runtime, service := newExtensionComposerValidationService(t)
runtime.startHook = func(input RuntimeStartInput, session ProviderRuntimeSession) ProviderRuntimeSession {
if input.Visible != nil && !*input.Visible {
session.RuntimeContext = map[string]any{
"configOptions": []any{map[string]any{
"id": "model",
"currentValue": "gemini-fast",
"options": []any{
map[string]any{"value": "gemini-pro", "name": "Gemini Pro"},
map[string]any{"value": "gemini-fast", "name": "Gemini Fast"},
},
}},
}
}
return session
}
service.AgentComposerDefaultsReader = fakeAgentComposerDefaultsReader{
extensionComposerValidationTargetID: {Model: "retired-model"},
}
created, err := service.Create(context.Background(), "workspace-extension", CreateSessionInput{
AgentTargetID: extensionComposerValidationTargetID,
Cwd: stringPointer(t.TempDir()),
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if created.Settings == nil || created.Settings.Model != "gemini-fast" {
t.Fatalf("created settings = %#v, want runtime current model", created.Settings)
}
visibleStarts := visibleRuntimeStarts(runtime.startCalls)
if len(visibleStarts) != 1 || visibleStarts[0].Model != "gemini-fast" {
t.Fatalf("visible starts = %#v, want runtime current model", visibleStarts)
}
}
func TestServiceCreateResolvesReasoningDefaultAfterRetiredExtensionModel(t *testing.T) {
runtime, service := newExtensionComposerValidationService(t)
runtime.startHook = func(input RuntimeStartInput, session ProviderRuntimeSession) ProviderRuntimeSession {
if input.Visible != nil && !*input.Visible {
session.RuntimeContext = map[string]any{
"configOptions": []any{map[string]any{
"id": "model",
"currentValue": "gemini-fast",
"options": []any{
map[string]any{"value": "gemini-pro", "name": "Gemini Pro"},
map[string]any{
"value": "gemini-fast",
"name": "Gemini Fast",
"reasoningEffort": "low",
"supportsReasoningEffort": true,
"reasoningEfforts": []any{
map[string]any{"value": "low", "name": "Low", "default": true},
},
},
},
}},
}
}
return session
}
service.AgentComposerDefaultsReader = fakeAgentComposerDefaultsReader{
extensionComposerValidationTargetID: {
Model: "retired-model",
ReasoningEffort: "high",
},
}
created, err := service.Create(context.Background(), "workspace-extension", CreateSessionInput{
AgentTargetID: extensionComposerValidationTargetID,
Cwd: stringPointer(t.TempDir()),
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if created.Settings == nil ||
created.Settings.Model != "gemini-fast" ||
created.Settings.ReasoningEffort != "low" {
t.Fatalf("created settings = %#v, want runtime model reasoning default", created.Settings)
}
visibleStarts := visibleRuntimeStarts(runtime.startCalls)
if len(visibleStarts) != 1 ||
visibleStarts[0].Model != "gemini-fast" ||
visibleStarts[0].ReasoningEffort != "low" {
t.Fatalf("visible starts = %#v, want resolved runtime model settings", visibleStarts)
}
}
func newExtensionComposerValidationService(t *testing.T) (*fakeRuntime, *Service) {
t.Helper()
runtime := newFakeRuntime()
runtime.startHook = func(input RuntimeStartInput, session ProviderRuntimeSession) ProviderRuntimeSession {
if input.Visible != nil && !*input.Visible {
session.RuntimeContext = map[string]any{
"configOptions": []any{map[string]any{
"id": "model",
"options": []any{
map[string]any{"value": "gemini-pro", "name": "Gemini Pro"},
map[string]any{"value": "gemini-fast", "name": "Gemini Fast"},
},
}},
}
}
return session
}
service := newIsolatedAgentService(runtime)
service.AgentTargetStore = fakeAgentTargetStore{targets: map[string]agenttargetbiz.Target{
extensionComposerValidationTargetID: {
ID: extensionComposerValidationTargetID,
Provider: "acp:gemini",
LaunchRefJSON: `{"type":"agent_extension","extensionInstallationId":"gemini@1.0.0"}`,
Name: "Gemini CLI",
Enabled: true,
Source: agenttargetbiz.SourceSystem,
},
}}
service.ExtensionComposerProfiles = extensionComposerProfileResolverStub{
profile: ExtensionComposerProfile{PermissionModes: []ExtensionComposerPermissionMode{
{RuntimeID: "default", Semantic: PermissionModeSemanticAskBeforeWrite},
{RuntimeID: "yolo", Semantic: PermissionModeSemanticFullAccess},
}},
}
return runtime, service
}
func visibleRuntimeStarts(starts []RuntimeStartInput) []RuntimeStartInput {
result := make([]RuntimeStartInput, 0, len(starts))
for _, start := range starts {
if start.Visible == nil || *start.Visible {
result = append(result, start)
}
}
return result
}