diff --git a/internal/sync/create/attachment.go b/internal/sync/create/attachment.go index 04fb735a..360bbe8f 100644 --- a/internal/sync/create/attachment.go +++ b/internal/sync/create/attachment.go @@ -19,53 +19,6 @@ import ( // not an input schema. var definitionKeys = []string{"formatVersion", "inputSchema", "schema"} -// Check applies the content rules of a create of a tool or a skill. It reads -// nothing, so an add runs it before any request. -func (input Input) Check(kind syncdomain.AttachmentKind) error { - if err := input.checkContentFlag(kind); err != nil { - return err - } - if kind == syncdomain.AttachmentTool { - _, err := parseSchema(*input.JSONSchema) - return err - } - switch { - case strings.TrimSpace(*input.Content) == "": - return errors.New("--content cannot be blank") - case input.Name != nil && strings.TrimSpace(*input.Name) == "": - return errors.New("--name cannot be blank") - } - return nil -} - -// checkContentFlag makes sure that the flag that starts a create is set when -// another create flag is. -func (input Input) checkContentFlag(kind syncdomain.AttachmentKind) error { - flag, content := "--content", input.Content - if kind == syncdomain.AttachmentTool { - flag, content = "--json-schema", input.JSONSchema - } - if content != nil { - return nil - } - - var passed []string - if input.Name != nil { - passed = append(passed, "--name") - } - if input.Description != nil { - passed = append(passed, "--description") - } - switch len(passed) { - case 0: - return fmt.Errorf("add creates a %s only when %s is set", kind, flag) - case 1: - return fmt.Errorf("%s needs %s. add creates a %s only when %s is set.", passed[0], flag, kind, flag) - default: - return fmt.Errorf("%s need %s. add creates a %s only when %s is set.", joinFlags(passed), flag, kind, flag) - } -} - // parseSchema decodes the value of --json-schema, which must be one JSON // object. func parseSchema(value string) (map[string]any, error) { @@ -126,7 +79,7 @@ func (input Input) Ask(id syncdomain.ResourceID, questions Questions) (Input, bo // from "$(cat file)" has the fingerprint of the file. func (input Input) Attachment(id syncdomain.ResourceID) (syncdomain.Attachment, error) { kind := syncdomain.AttachmentKind(id.Kind) - if err := input.Check(kind); err != nil { + if err := input.Check(id.Kind); err != nil { return syncdomain.Attachment{}, err } diff --git a/internal/sync/create/create.go b/internal/sync/create/create.go index f2fcfa7e..aca32791 100644 --- a/internal/sync/create/create.go +++ b/internal/sync/create/create.go @@ -4,6 +4,7 @@ package create import ( + "errors" "fmt" "io" "strings" @@ -21,9 +22,63 @@ type Input struct { Content *string // JSONSchema is the input schema of a tool. JSONSchema *string - // Name is the name of a skill. - Name *string + // Name is the name of a skill or a variation. + Name *string + // Description is the description of a tool or a skill. Description *string + // ModelConfigKey is the model config of a variation. + ModelConfigKey *string +} + +// Check applies the content rules of a create of kind. It reads nothing, so +// an add runs it before any request. +func (input Input) Check(kind syncdomain.Kind) error { + if err := input.checkContentFlag(kind); err != nil { + return err + } + if kind == syncdomain.KindTool { + _, err := parseSchema(*input.JSONSchema) + return err + } + switch { + case strings.TrimSpace(*input.Content) == "": + return errors.New("--content cannot be blank") + case input.Name != nil && strings.TrimSpace(*input.Name) == "": + return errors.New("--name cannot be blank") + case input.ModelConfigKey != nil && strings.TrimSpace(*input.ModelConfigKey) == "": + return errors.New("--model-config-key cannot be blank") + } + return nil +} + +// checkContentFlag makes sure that the flag that starts a create is set when +// another create flag is. +func (input Input) checkContentFlag(kind syncdomain.Kind) error { + flag, content := "--content", input.Content + if kind == syncdomain.KindTool { + flag, content = "--json-schema", input.JSONSchema + } + if content != nil { + return nil + } + + var passed []string + for _, option := range []struct { + flag string + value *string + }{{"--name", input.Name}, {"--description", input.Description}, {"--model-config-key", input.ModelConfigKey}} { + if option.value != nil { + passed = append(passed, option.flag) + } + } + switch len(passed) { + case 0: + return fmt.Errorf("add creates a %s only when %s is set", kind, flag) + case 1: + return fmt.Errorf("%s needs %s. add creates a %s only when %s is set.", passed[0], flag, kind, flag) + default: + return fmt.Errorf("%s need %s. add creates a %s only when %s is set.", joinFlags(passed), flag, kind, flag) + } } // Questions are the streams that ask the user for the values that the flags diff --git a/internal/sync/create/create_test.go b/internal/sync/create/create_test.go index 0accc520..08a0f94b 100644 --- a/internal/sync/create/create_test.go +++ b/internal/sync/create/create_test.go @@ -17,27 +17,32 @@ var ( func TestCheckNeedsTheFlagThatStartsTheCreate(t *testing.T) { tests := map[string]struct { - kind syncdomain.AttachmentKind + kind syncdomain.Kind input Input want string }{ "tool description": { - kind: syncdomain.AttachmentTool, + kind: syncdomain.KindTool, input: Input{Description: pointer("Issue a refund")}, want: "--description needs --json-schema. add creates a tool only when --json-schema is set.", }, "skill name": { - kind: syncdomain.AttachmentSkill, + kind: syncdomain.KindSkill, input: Input{Name: pointer("Triage")}, want: "--name needs --content. add creates a skill only when --content is set.", }, "skill name and description": { - kind: syncdomain.AttachmentSkill, + kind: syncdomain.KindSkill, input: Input{Name: pointer("Triage"), Description: pointer("")}, want: "--name and --description need --content. add creates a skill only when --content is set.", }, + "variation name and model config": { + kind: syncdomain.KindVariation, + input: Input{Name: pointer("Escalation"), ModelConfigKey: pointer("claude")}, + want: "--name and --model-config-key need --content. add creates a variation only when --content is set.", + }, "no flag": { - kind: syncdomain.AttachmentTool, + kind: syncdomain.KindTool, want: "add creates a tool only when --json-schema is set", }, } @@ -68,7 +73,7 @@ func TestCheckDecodesTheJSONSchemaStrictly(t *testing.T) { } for name, test := range tests { t.Run(name, func(t *testing.T) { - err := Input{JSONSchema: pointer(test.schema)}.Check(syncdomain.AttachmentTool) + err := Input{JSONSchema: pointer(test.schema)}.Check(syncdomain.KindTool) if test.want == "" { require.NoError(t, err) return @@ -79,10 +84,10 @@ func TestCheckDecodesTheJSONSchemaStrictly(t *testing.T) { } func TestCheckRejectsBlankSkillValues(t *testing.T) { - require.EqualError(t, Input{Content: pointer(" \n")}.Check(syncdomain.AttachmentSkill), "--content cannot be blank") - require.EqualError(t, Input{Content: pointer("Triage."), Name: pointer(" ")}.Check(syncdomain.AttachmentSkill), + require.EqualError(t, Input{Content: pointer(" \n")}.Check(syncdomain.KindSkill), "--content cannot be blank") + require.EqualError(t, Input{Content: pointer("Triage."), Name: pointer(" ")}.Check(syncdomain.KindSkill), "--name cannot be blank") - require.NoError(t, Input{Content: pointer("Triage."), Description: pointer("")}.Check(syncdomain.AttachmentSkill)) + require.NoError(t, Input{Content: pointer("Triage."), Description: pointer("")}.Check(syncdomain.KindSkill)) } func TestAttachmentBuildsATool(t *testing.T) { diff --git a/internal/sync/create/prompt.go b/internal/sync/create/prompt.go index 94fccf6b..ae3780a1 100644 --- a/internal/sync/create/prompt.go +++ b/internal/sync/create/prompt.go @@ -4,10 +4,13 @@ import ( "fmt" "slices" + "github.com/charmbracelet/huh" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" ) // Catalog finds the project, the config, and the model config of a new @@ -80,3 +83,147 @@ func FindModelConfig(catalog Catalog, projectKey, key string) (syncapi.ModelConf } return modelConfigs[index], nil } + +// VariationTarget returns the identity of a new variation. The filter gives +// the project, the config, and the key, and a question asks for each one that +// it does not give. Without questions, the error names every missing flag, +// --model-config-key too, before any read. The bool result is true when the +// user cancels. +func (input Input) VariationTarget(catalog Catalog, filter syncdomain.Filter, questions Questions) (syncdomain.ResourceID, bool, error) { + var key string + if len(filter.Keys) == 1 { + key = filter.Keys[0] + } + var missing []string + for _, flag := range []struct { + name string + set bool + }{{"--project", filter.Project != ""}, {"--config-key", filter.ConfigKey != ""}, {"--key", key != ""}, {"--model-config-key", input.ModelConfigKey != nil}} { + if !flag.set { + missing = append(missing, flag.name) + } + } + if len(missing) != 0 && !questions.Allowed { + return syncdomain.ResourceID{}, false, fmt.Errorf("variation creation requires %s without interactive input", joinFlags(missing)) + } + if key != "" { + if err := keyValidator(syncdomain.KindVariation)(key); err != nil { + return syncdomain.ResourceID{}, false, err + } + } + + projectKey, configKey := filter.Project, filter.ConfigKey + if configKey == "" { + project, config, canceled, err := SelectConfig(questions, catalog, projectKey, nil) + if err != nil || canceled { + return syncdomain.ResourceID{}, canceled, err + } + projectKey, configKey = project.Key, config.Key + } + if key == "" { + field := huh.NewInput().Title("Variation key").Value(&key).Validate(keyValidator(syncdomain.KindVariation)) + if canceled, err := syncinteractive.RunForm(questions.Input, questions.Output, field); err != nil || canceled { + return syncdomain.ResourceID{}, canceled, err + } + } + return syncdomain.VariationID(projectKey, configKey, key), false, nil +} + +// Variation returns the variation that the input creates with id in config. +// It uses the latest version of its model config. A question asks for the +// model config and the name that the flags do not give. Without questions, +// the name comes from the key. The bool result is true when the user cancels. +func (input Input) Variation( + catalog Catalog, + id syncdomain.ResourceID, + config syncapi.Config, + questions Questions, +) (syncdomain.Variation, bool, error) { + prompt, err := input.withContent(syncdomain.Variation{Mode: config.Mode}) + if err != nil { + return syncdomain.Variation{}, false, err + } + + var modelConfig syncapi.ModelConfig + canceled := false + if input.ModelConfigKey != nil { + modelConfig, err = FindModelConfig(catalog, id.ProjectKey, *input.ModelConfigKey) + } else { + modelConfig, canceled, err = SelectModelConfig(questions, catalog, id.ProjectKey) + } + if err != nil || canceled { + return syncdomain.Variation{}, canceled, err + } + + _, key, err := id.VariationKeys() + if err != nil { + return syncdomain.Variation{}, false, err + } + name := syncdomain.DisplayName(key) + if input.Name != nil { + name = *input.Name + } else if questions.Allowed { + field := huh.NewInput().Title("Variation name").Value(&name).Validate(RequiredValue("variation name")) + if canceled, err := syncinteractive.RunForm(questions.Input, questions.Output, field); err != nil || canceled { + return syncdomain.Variation{}, canceled, err + } + } + if err := syncdomain.ValidateCreate(syncdomain.KindVariation, key, name, ""); err != nil { + return syncdomain.Variation{}, false, err + } + + variation := NewVariation(config, key, name, modelConfig) + variation.Instructions, variation.Messages = prompt.Instructions, prompt.Messages + if err := variation.Validate(); err != nil { + return syncdomain.Variation{}, false, fmt.Errorf("variation %q: %w", key, err) + } + return variation, false, nil +} + +// NewVariation returns a variation in config with no prompt. It uses the +// latest version of modelConfig. +func NewVariation(config syncapi.Config, key, name string, modelConfig syncapi.ModelConfig) syncdomain.Variation { + return syncdomain.Variation{ + Mode: config.Mode, + Key: key, + Name: name, + ModelConfigKey: modelConfig.Key, + ModelConfigVersion: modelConfig.Version, + Model: modelConfig.VariationModel(), + } +} + +// MatchesVariation reports whether LaunchDarkly already has the value of each +// flag that the user passed: the prompt, and the name and the model config key +// when they are set. id is the identity of existing. The fields without a +// flag, such as the model config version and the tools, come from +// LaunchDarkly. +func (input Input) MatchesVariation(id syncdomain.ResourceID, existing syncdomain.Variation) (bool, error) { + candidate, err := input.withContent(existing) + if err != nil { + return false, err + } + if input.Name != nil { + candidate.Name = *input.Name + } + if input.ModelConfigKey != nil { + candidate.ModelConfigKey = *input.ModelConfigKey + } + want, err := syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, candidate) + if err != nil { + return false, err + } + have, err := syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, existing) + return want == have, err +} + +// withContent returns variation with the prompt of --content. It reads the +// content with the body rules of a variation file: the instructions of an +// agent, or the role blocks of a completion, in which plain text is one system +// message. +func (input Input) withContent(variation syncdomain.Variation) (syncdomain.Variation, error) { + if err := synclocal.ParsePromptBody(*input.Content, &variation); err != nil { + return syncdomain.Variation{}, fmt.Errorf("--content: %w", err) + } + return variation, nil +} diff --git a/internal/sync/create/prompt_test.go b/internal/sync/create/prompt_test.go index ef59c11a..c96a642f 100644 --- a/internal/sync/create/prompt_test.go +++ b/internal/sync/create/prompt_test.go @@ -33,6 +33,86 @@ func TestSelectConfigSkipsTheProjectQuestionAndNamesAMissingProject(t *testing.T assert.Equal(t, []string{"demo"}, catalog.configSearches) } +func TestVariationTargetWithoutQuestionsNamesEveryMissingFlag(t *testing.T) { + _, _, err := Input{Content: pointer("Escalate.")}.VariationTarget(&fakeCatalog{}, syncdomain.Filter{}, Questions{}) + require.EqualError(t, err, + "variation creation requires --project, --config-key, --key, and --model-config-key without interactive input") + + input := Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")} + filter := syncdomain.Filter{Project: "demo", ConfigKey: "support", Keys: []string{"escalation"}} + id, canceled, err := input.VariationTarget(&fakeCatalog{}, filter, Questions{}) + require.NoError(t, err) + assert.False(t, canceled) + assert.Equal(t, syncdomain.VariationID("demo", "support", "escalation"), id) +} + +func TestVariationReadsTheContentWithTheRulesOfAVariationFile(t *testing.T) { + catalog := &fakeCatalog{modelConfigs: []syncapi.ModelConfig{{Key: "claude", ID: "claude-sonnet", Version: 3}}} + id := syncdomain.VariationID("demo", "support", "incident-escalation") + tests := map[string]struct { + mode syncdomain.VariationMode + content string + want syncdomain.Variation + }{ + "agent instructions": { + mode: syncdomain.VariationModeAgent, content: "Escalate.\n", + want: syncdomain.Variation{Instructions: "Escalate."}, + }, + "completion role blocks": { + mode: syncdomain.VariationModeCompletion, content: "Be brief.Escalate.", + want: syncdomain.Variation{Messages: []syncdomain.Message{ + {Role: syncdomain.RoleSystem, Content: "Be brief."}, {Role: "user", Content: "Escalate."}, + }}, + }, + "completion plain text": { + mode: syncdomain.VariationModeCompletion, content: "Escalate.", + want: syncdomain.Variation{Messages: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Escalate."}}}, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + input := Input{Content: &test.content, ModelConfigKey: pointer("claude")} + + variation, canceled, err := input.Variation(catalog, id, syncapi.Config{Key: "support", Mode: test.mode}, Questions{}) + + require.NoError(t, err) + assert.False(t, canceled) + want := test.want + want.Mode, want.Key, want.Name = test.mode, "incident-escalation", "Incident escalation" + want.ModelConfigKey, want.ModelConfigVersion = "claude", 3 + want.Model = catalog.modelConfigs[0].VariationModel() + assert.Equal(t, want, variation) + }) + } +} + +func TestMatchesVariationComparesOnlyThePassedFields(t *testing.T) { + id := syncdomain.VariationID("demo", "support", "escalation") + existing := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Escalate.", + ModelConfigKey: "claude", ModelConfigVersion: 2, Model: map[string]any{"modelName": "claude-sonnet"}, + Tools: []syncdomain.AttachmentRef{{Key: "search", Version: 4}}, + } + tests := map[string]struct { + input Input + want bool + }{ + "prompt only": {input: Input{Content: pointer("Escalate.\n")}, want: true}, + "same name and model": {input: Input{Content: pointer("Escalate."), Name: pointer("Escalation"), ModelConfigKey: pointer("claude")}, want: true}, + "other prompt": {input: Input{Content: pointer("Escalate now.")}}, + "other name": {input: Input{Content: pointer("Escalate."), Name: pointer("Escalate")}}, + "other model config key": {input: Input{Content: pointer("Escalate."), ModelConfigKey: pointer("gpt")}}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + matches, err := test.input.MatchesVariation(id, existing) + + require.NoError(t, err) + assert.Equal(t, test.want, matches) + }) + } +} + // fakeCatalog has model configs. It fails a project search, and answers a // config search with a 404, as LaunchDarkly does for a missing project. type fakeCatalog struct { diff --git a/internal/sync/link/link.go b/internal/sync/link/link.go index fe1c9eaf..bbf6ed76 100644 --- a/internal/sync/link/link.go +++ b/internal/sync/link/link.go @@ -217,14 +217,7 @@ func addMissingPromptContent( // linked file, and creates the variation file. If the variation file fails, // it restores the linked file. func createLinkedPrompt(options Options, selection Selection, prompt linkedPrompt) (string, error) { - variation := syncdomain.Variation{ - Mode: selection.Config.Mode, - Key: selection.Key, - Name: selection.Name, - ModelConfigKey: selection.ModelConfig.Key, - ModelConfigVersion: selection.ModelConfig.Version, - Model: selection.ModelConfig.VariationModel(), - } + variation := synccreate.NewVariation(selection.Config, selection.Key, selection.Name, selection.ModelConfig) if err := syncreference.ApplyToVariation(prompt.reference.Format, prompt.content, &variation); err != nil { return "", err } diff --git a/internal/sync/local/variation.go b/internal/sync/local/variation.go index d31b9ea2..489b362a 100644 --- a/internal/sync/local/variation.go +++ b/internal/sync/local/variation.go @@ -75,7 +75,7 @@ func parseVariation(file localFile) (syncdomain.SyncedResource, error) { if stem := variationStem(file.RelPath); variation.Key != stem { return syncdomain.SyncedResource{}, fmt.Errorf("referenced prompt key %q does not match filename %q", variation.Key, stem) } - } else if err := parsePromptBody(string(body), &variation); err != nil { + } else if err := ParsePromptBody(string(body), &variation); err != nil { return syncdomain.SyncedResource{}, err } @@ -118,9 +118,9 @@ func parseVariationFrontMatter(relPath string, data []byte) (variationFrontMatte return meta, body, nil } -// parsePromptBody stores an inline body as agent instructions or as +// ParsePromptBody stores an inline body as agent instructions or as // completion messages. -func parsePromptBody(body string, variation *syncdomain.Variation) error { +func ParsePromptBody(body string, variation *syncdomain.Variation) error { if variation.Mode == syncdomain.VariationModeAgent { variation.Instructions = syncdomain.NormalizePromptText(body) return nil diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index c0b77f97..da1d25f9 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -2,6 +2,7 @@ package prompt_test import ( "bytes" + "cmp" "encoding/json" "errors" "fmt" @@ -32,12 +33,15 @@ import ( type directAPI struct { variation *syncdomain.Variation variationState string - modelConfigs []syncapi.ModelConfig - tools map[string]versionedTool - skills map[string]versionedSkill - manifest *syncapi.SyncManifest - requests []string - // createErr fails each create of a tool or a skill after onCreate runs. + // configMode is the mode of the config "support". The empty mode is agent. + configMode syncdomain.VariationMode + modelConfigs []syncapi.ModelConfig + tools map[string]versionedTool + skills map[string]versionedSkill + manifest *syncapi.SyncManifest + requests []string + // createErr fails each create of a tool, a skill, or a variation after + // onCreate runs. // onCreate can add the resource, as another working copy does. createErr error onCreate func() @@ -92,7 +96,8 @@ func (api *directAPI) MakeRequest( if strings.HasSuffix(path, "/configs/sync/manifests") { return handleManifestRequest(api.manifest, method, query, body) } - isCreate := method == http.MethodPost && (strings.HasSuffix(path, "/ai-tools") || strings.HasSuffix(path, "/ai-configs/skills")) + isCreate := method == http.MethodPost && + (strings.HasSuffix(path, "/ai-tools") || strings.HasSuffix(path, "/ai-configs/skills") || strings.HasSuffix(path, "/variations")) if isCreate && api.createErr != nil { if api.onCreate != nil { api.onCreate() @@ -155,6 +160,9 @@ func (api *directAPI) MakeRequest( } return json.Marshal(skill) } + if method == "GET" && strings.HasSuffix(path, "/model-configs") { + return json.Marshal(api.modelConfigs) + } if method == "GET" && strings.Contains(path, "/model-configs/") { for _, modelConfig := range api.modelConfigs { if strings.HasSuffix(path, "/"+modelConfig.Key) { @@ -174,14 +182,14 @@ func (api *directAPI) MakeRequest( }) } return json.Marshal(map[string]any{ - "key": "support", "name": "Support", "mode": "agent", "variations": variations, + "key": "support", "name": "Support", "mode": cmp.Or(api.configMode, syncdomain.VariationModeAgent), "variations": variations, }) case "POST": var variation syncdomain.Variation if err := json.Unmarshal(body, &variation); err != nil { return nil, err } - variation.Mode = syncdomain.VariationModeAgent + variation.Mode = cmp.Or(api.configMode, syncdomain.VariationModeAgent) api.variation = &variation api.variationState = "published" case "PATCH": diff --git a/internal/sync/prompt/action.go b/internal/sync/prompt/action.go index 1d58e69b..9c4199b0 100644 --- a/internal/sync/prompt/action.go +++ b/internal/sync/prompt/action.go @@ -80,13 +80,14 @@ func (action AddAction) validate(scope syncdomain.Scope) error { if action.Create == nil { return nil } - if !syncdomain.AttachmentKind(kind).Valid() { - return errors.New("add creates only tools and skills") + if len(action.Resources) != 0 { + return errors.New("a variation that add creates comes from --project, --config-key, and --key, " + + "not from project-key/config-key/variation-key arguments") } - if len(selections(action.Resources, action.Filter, kind)) > 1 { + if len(action.Filter.Keys) > 1 { return fmt.Errorf("add creates one %s at a time. Pass at most one --key.", kind) } - return action.Create.Check(syncdomain.AttachmentKind(kind)) + return action.Create.Check(kind) } func (action DetachAction) validate(scope syncdomain.Scope) error { diff --git a/internal/sync/prompt/create.go b/internal/sync/prompt/create.go index 68484e3d..a15c9698 100644 --- a/internal/sync/prompt/create.go +++ b/internal/sync/prompt/create.go @@ -3,6 +3,7 @@ package prompt import ( "errors" "fmt" + "slices" syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" @@ -12,18 +13,20 @@ import ( synclocal "github.com/launchdarkly/ldcli/internal/sync/local" ) -// runCreate creates one tool or skill from the create flags of add. Add never +// runCreate creates one resource from the create flags of add. Add never // changes a resource that LaunchDarkly has. It starts syncing one that has the // content of the flags, and refuses one with other content. func (runner Runner) runCreate(options Options, workspace syncWorkspace, action AddAction) error { - client := runner.api(options) - questions := synccreate.Questions{ - // The forms write to the output stream, so check that stream. - Allowed: !options.NoInput && runner.isTerminal(options.Input, options.Output), - Input: options.Input, - Output: options.Output, - Projects: client, + if options.Scope.KeyKind() == syncdomain.KindVariation { + return runner.createVariation(options, workspace, action) } + return runner.createAttachment(options, workspace, action) +} + +// createAttachment creates a tool or a skill. +func (runner Runner) createAttachment(options Options, workspace syncWorkspace, action AddAction) error { + client := runner.api(options) + questions := runner.createQuestions(options, client) var key string if len(action.Filter.Keys) == 1 { key = action.Filter.Keys[0] @@ -45,7 +48,13 @@ func (runner Runner) runCreate(options Options, workspace syncWorkspace, action existing, err := client.ReadAttachment(id.ProjectKey, created.Kind, id.LookupKey) switch { case err == nil: - return runner.adoptAttachment(options, workspace, action, id, created, existing) + if err := runner.adopt(options, workspace, action, id, input.Matches(created, existing)); err != nil { + return err + } + if name := input.Name; name != nil && existing.Skill != nil && *name != existing.Skill.Name { + _ = syncconsole.New(options.ErrorOutput).Printf("Note: %s keeps its LaunchDarkly name %q.\n", id.Label(), existing.Skill.Name) + } + return nil case !syncapi.IsNotFound(err): return err } @@ -57,58 +66,117 @@ func (runner Runner) runCreate(options Options, workspace syncWorkspace, action if created, err = input.Attachment(id); err != nil { return err } - return runner.createAttachment(options, workspace, client, syncdomain.SyncedAttachment{ - ProjectKey: id.ProjectKey, Attachment: created, - }, input.Name != nil) + file := syncdomain.SyncedAttachment{ProjectKey: id.ProjectKey, Attachment: created} + row := planAttachment(id, &file.Attachment, nil, "", false) + if id.Kind == syncdomain.KindSkill { + row.nameSource = nameFromKey + if input.Name != nil { + row.nameSource = nameFromFlag + } + } + return runner.createResource(options, workspace, client, row, func() (synclocal.Creation, error) { + paths, err := workspace.local.ReplaceAttachments([]syncdomain.SyncedAttachment{file}) + return synclocal.Creation{CreatedPaths: paths}, err + }) } -// adoptAttachment starts syncing a tool or skill that LaunchDarkly already -// has, with the add of a resource that exists. It refuses when a flag that the -// user passed differs from LaunchDarkly. -func (runner Runner) adoptAttachment( - options Options, - workspace syncWorkspace, - action AddAction, - id ResourceID, - created, existing syncdomain.Attachment, -) error { - if !action.Create.Matches(created, existing) { +// createVariation creates a prompt variation in a config that exists. +func (runner Runner) createVariation(options Options, workspace syncWorkspace, action AddAction) error { + client := runner.api(options) + questions := runner.createQuestions(options, client) + input := *action.Create + id, canceled, err := input.VariationTarget(client, action.Filter, questions) + if err != nil || canceled { + return err + } + if err := syncbootstrap.CheckNotSynced(workspace.local, []ResourceID{id}); err != nil { + return err + } + + configKey, variationKey, err := id.VariationKeys() + if err != nil { + return err + } + config, err := client.Config(id.ProjectKey, configKey) + if err != nil { + return err + } + if index := slices.IndexFunc(config.Variations, func(existing syncdomain.Variation) bool { return existing.Key == variationKey }); index >= 0 { + matches, err := input.MatchesVariation(id, config.Variations[index]) + if err != nil { + return err + } + return runner.adopt(options, workspace, action, id, matches) + } + if slices.Contains(config.ArchivedVariationKeys, variationKey) { + return fmt.Errorf("%s is archived in LaunchDarkly, and a new variation cannot use its key. Pass another --key.", id.Label()) + } + + variation, canceled, err := input.Variation(client, id, config, questions) + if err != nil || canceled { + return err + } + file := syncdomain.SyncedResource{ + Kind: syncdomain.KindVariation, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, Upsert: true, Variation: variation, + } + row := planResource(id, &file, ServerResource{ConfigMode: config.Mode}, "", false) + return runner.createResource(options, workspace, client, row, func() (synclocal.Creation, error) { + return workspace.local.Add([]synclocal.VariationFile{{ + ProjectKey: id.ProjectKey, ConfigKey: configKey, Upsert: true, Variation: variation, + }}) + }) +} + +// createQuestions are the questions of a create. The forms write to the +// output stream, so the check for a terminal uses that stream. +func (runner Runner) createQuestions(options Options, client syncapi.Client) synccreate.Questions { + return synccreate.Questions{ + Allowed: !options.NoInput && runner.isTerminal(options.Input, options.Output), + Input: options.Input, + Output: options.Output, + Projects: client, + } +} + +// adopt starts syncing a resource that LaunchDarkly already has, with the add +// of a resource that exists. matches is false when a flag that the user passed +// differs from LaunchDarkly, and then adopt refuses. +func (runner Runner) adopt(options Options, workspace syncWorkspace, action AddAction, id ResourceID, matches bool) error { + if !matches { return fmt.Errorf("%s already exists in LaunchDarkly with different content, and add does not change it. "+ - "To sync the LaunchDarkly version, run %s, then edit the file and run ldcli sync %ss or ldcli sync all.", id.Label(), syncbootstrap.AddCommand(id), id.Kind) + "To sync the LaunchDarkly version, run %s, then edit the file and run %s or ldcli sync all.", + id.Label(), syncbootstrap.AddCommand(id), syncbootstrap.SyncCommand(id.Kind)) } - adopt := AddAction{Filter: syncdomain.Filter{Project: id.ProjectKey, Keys: []string{id.LookupKey}}, DryRun: action.DryRun} - if err := runner.runAdd(options, workspace, adopt); err != nil { + if err := runner.runAdd(options, workspace, AddAction{Filter: resourceFilter(id), DryRun: action.DryRun}); err != nil { return err } - notes := syncconsole.New(options.ErrorOutput) started := "started" if action.DryRun { started = "will start" } - _ = notes.Printf("Note: %s already exists in LaunchDarkly with this content, so add %s syncing it.\n", id.Label(), started) - if name := action.Create.Name; name != nil && existing.Skill != nil && *name != existing.Skill.Name { - _ = notes.Printf("Note: %s keeps its LaunchDarkly name %q.\n", id.Label(), existing.Skill.Name) - } + _ = syncconsole.New(options.ErrorOutput).Printf( + "Note: %s already exists in LaunchDarkly with this content, so add %s syncing it.\n", id.Label(), started, + ) return nil } -// createAttachment creates a tool or skill that LaunchDarkly does not have. It -// plans the create in memory and asks before it writes the file, so that a -// cancel leaves nothing and a watch cannot create the resource before the -// answer. named is true when the user chose the name of a skill. -func (runner Runner) createAttachment( +// createResource creates a resource that LaunchDarkly does not have. row is +// the create row of the resource, and write writes its file. It plans the +// create in memory and asks before it writes the file, so that a cancel +// leaves nothing and a watch cannot create the resource before the answer. +func (runner Runner) createResource( options Options, workspace syncWorkspace, client syncapi.Client, - created syncdomain.SyncedAttachment, - named bool, + row PlannedResource, + write func() (synclocal.Creation, error), ) error { - path, err := synclocal.ResourcePath(created.ID()) + path, err := synclocal.ResourcePath(row.ID) if err != nil { return err } - reviewed := reviewedCreate(options.Scope, created, named) + reviewed := workspaceState{projectKeys: []string{row.ID.ProjectKey}, plan: Plan{Scope: options.Scope, Resources: []PlannedResource{row}}} if options.dryRun() { if err := writePlanOutput(options.Output, options.OutputKind, reviewed.plan, options.Diff); err != nil { return err @@ -121,28 +189,14 @@ func (runner Runner) createAttachment( } // A file can appear while the question waits. - if err := syncbootstrap.CheckNotSynced(workspace.local, []ResourceID{created.ID()}); err != nil { + if err := syncbootstrap.CheckNotSynced(workspace.local, []ResourceID{row.ID}); err != nil { return err } - written, err := workspace.local.ReplaceAttachments([]syncdomain.SyncedAttachment{created}) + creation, err := write() if err != nil { return err } - return runner.applyCreate(options, workspace, client, reviewed, path, synclocal.Creation{CreatedPaths: written}) -} - -// reviewedCreate is the state that the user reviews before a create: the -// create row of the new resource, with no file yet. -func reviewedCreate(scope syncdomain.Scope, created syncdomain.SyncedAttachment, named bool) workspaceState { - id := created.ID() - row := planAttachment(id, &created.Attachment, nil, "", false) - if id.Kind == syncdomain.KindSkill { - row.nameSource = nameFromKey - if named { - row.nameSource = nameFromFlag - } - } - return workspaceState{projectKeys: []string{id.ProjectKey}, plan: Plan{Scope: scope, Resources: []PlannedResource{row}}} + return runner.applyCreate(options, workspace, client, reviewed, path, creation) } // applyCreate applies the reviewed create after it wrote the new file at path. @@ -169,14 +223,14 @@ func (runner Runner) applyCreate( } id := reviewed.plan.Resources[0].ID - current, err := workspace.loadState(client, options.Scope, syncdomain.Filter{Project: id.ProjectKey, Keys: []string{id.LookupKey}}) + current, err := workspace.loadState(client, options.Scope, resourceFilter(id)) if err == nil { err = checkUnchanged(options, reviewed, current) } if err != nil { return remove(err) } - // The new file has the name of the skill, but only the reviewed row knows + // The new file of a skill has its name, but only the reviewed row knows // where the name came from. current.plan.Resources[0].nameSource = reviewed.plan.Resources[0].nameSource @@ -189,3 +243,11 @@ func (runner Runner) applyCreate( } return err } + +// resourceFilter is the filter that keeps only one resource. +func resourceFilter(id ResourceID) syncdomain.Filter { + if configKey, variationKey, err := id.VariationKeys(); err == nil { + return syncdomain.Filter{Project: id.ProjectKey, ConfigKey: configKey, Keys: []string{variationKey}} + } + return syncdomain.Filter{Project: id.ProjectKey, Keys: []string{id.LookupKey}} +} diff --git a/internal/sync/prompt/create_test.go b/internal/sync/prompt/create_test.go index 68e0bb7f..20fc0aff 100644 --- a/internal/sync/prompt/create_test.go +++ b/internal/sync/prompt/create_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" synccreate "github.com/launchdarkly/ldcli/internal/sync/create" syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt" ) @@ -550,3 +551,245 @@ func (reader *answerReader) Read(buffer []byte) (int, error) { } return copy(buffer, reader.answer), nil } + +var ( + escalationID = syncdomain.VariationID("production", "support", "escalation") + escalationFile = "configs/support/escalation.prompt.md" + claude = syncapi.ModelConfig{Key: "claude", ID: "claude-sonnet", Name: "Claude", Version: 3} +) + +func TestCreateMakesAnAgentVariationFromFlags(t *testing.T) { + root := initRepository(t) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{{Key: "gpt", ID: "gpt-5", Version: 1}, claude}} + action := createVariationAction("escalation", synccreate.Input{ + Content: pointer("Hand the conversation to a person."), ModelConfigKey: pointer("claude"), + }) + + stdout, notes, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.NoError(t, err) + assert.JSONEq(t, `{"resources": [{"resourceKind": "variation", "projectKey": "production", "lookupKey": "support/escalation", + "action": "create_server", "status": "succeeded"}]}`, stdout) + assert.Equal(t, "Note: Created .launchdarkly/production/"+escalationFile+".\n", notes) + assert.Equal(t, &syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Hand the conversation to a person.", + ModelConfigKey: "claude", ModelConfigVersion: 3, Model: claude.VariationModel(), + }, api.variation) + file := readLocalFile(t, root, escalationFile) + assert.Contains(t, file, "upsert: true\nmode: agent\nkey: escalation\nname: Escalation\nmodelConfigKey: claude\nmodelConfigVersion: 3\n") + assert.NotContains(t, file, "ref:") + assert.Equal(t, []syncdomain.ResourceID{escalationID}, lockResourceIDs(readLock(t, root))) + + stdout, err = runSyncAction(t, root, api, syncdomain.ScopePrompts, syncprompt.SyncAction{DryRun: true}, false) + require.NoError(t, err) + assert.Equal(t, []string{"variation support/escalation in_sync"}, planRows(t, stdout)) +} + +func TestCreateMakesACompletionVariationFromFlags(t *testing.T) { + tests := map[string]struct { + content string + want []syncdomain.Message + }{ + "role blocks": { + content: "Be brief.\n{{question}}", + want: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be brief."}, {Role: "user", Content: "{{question}}"}}, + }, + "plain text": { + content: "Be brief.", + want: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be brief."}}, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := initRepository(t) + api := &directAPI{configMode: syncdomain.VariationModeCompletion, modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: &test.content, ModelConfigKey: pointer("claude")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.NoError(t, err) + require.NotNil(t, api.variation) + assert.Equal(t, test.want, api.variation.Messages) + assert.Empty(t, api.variation.Instructions) + }) + } +} + +func TestCreateVariationLeavesOtherPendingChangesAsTheyAre(t *testing.T) { + root := initRepository(t) + writeLocalFile(t, root, "tools/refund.json", `{"formatVersion": 1, "upsert": true, "key": "refund", "schema": {"type": "object"}}`) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.NoError(t, err) + require.NotNil(t, api.variation) + assert.NotContains(t, api.tools, "refund", "the new tool waits for a sync") + assert.Equal(t, []syncdomain.ResourceID{escalationID}, lockResourceIDs(readLock(t, root))) +} + +func TestCreateAdoptsAVariationWhoseModelConfigHasANewerVersion(t *testing.T) { + root := initRepository(t) + older := claude + older.Version = 2 + existing := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Escalate.", + ModelConfigKey: "claude", ModelConfigVersion: 2, Model: older.VariationModel(), + } + api := &directAPI{variation: &existing, variationState: "published", modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{ + Content: pointer("Escalate."), Name: pointer("Escalation"), ModelConfigKey: pointer("claude"), + }) + + stdout, notes, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.NoError(t, err) + assert.Equal(t, "Bootstrapped 1 variation file in .launchdarkly.\n", stdout) + assert.Equal(t, "Note: variation production/support/escalation already exists in LaunchDarkly with this content, "+ + "so add started syncing it.\n", notes) + requireOnlyReads(t, api.requests) + assert.Contains(t, readLocalFile(t, root, escalationFile), "modelConfigVersion: 2\n", "the file takes the version from LaunchDarkly") +} + +func TestCreateRefusesAVariationWithOtherContent(t *testing.T) { + refusal := "variation production/support/escalation already exists in LaunchDarkly with different content, " + + "and add does not change it. To sync the LaunchDarkly version, run " + + "ldcli sync prompts add --project production --config-key support --key escalation, then edit the file and run ldcli sync prompts or ldcli sync all." + tests := map[string]synccreate.Input{ + "prompt": {Content: pointer("Escalate now."), ModelConfigKey: pointer("claude")}, + "name": {Content: pointer("Escalate."), Name: pointer("Escalate"), ModelConfigKey: pointer("claude")}, + "model config": {Content: pointer("Escalate."), ModelConfigKey: pointer("gpt")}, + } + for name, input := range tests { + t.Run(name, func(t *testing.T) { + root := initRepository(t) + api := &directAPI{variation: &syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Escalate.", + ModelConfigKey: "claude", ModelConfigVersion: 3, Model: claude.VariationModel(), + }, variationState: "published", modelConfigs: []syncapi.ModelConfig{claude}} + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, Action: createVariationAction("escalation", input), Yes: true, + }) + + require.EqualError(t, err, refusal) + requireOnlyReads(t, api.requests) + assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) + }) + } +} + +func TestCreateRefusesTheKeyOfAnArchivedVariation(t *testing.T) { + root := initRepository(t) + api := &directAPI{variation: &syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "escalation", Name: "Escalation", Instructions: "Escalate.", + }, variationState: "archived", modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.EqualError(t, err, "variation production/support/escalation is archived in LaunchDarkly, "+ + "and a new variation cannot use its key. Pass another --key.") + requireOnlyReads(t, api.requests) + assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) +} + +func TestCreateVariationChecksTheFlagsBeforeAnyRequest(t *testing.T) { + valid := synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")} + tests := map[string]struct { + action syncprompt.AddAction + want string + }{ + "no target flags": { + action: syncprompt.AddAction{Create: &valid}, + want: "variation creation requires --project, --config-key, and --key without interactive input", + }, + "no model config": { + action: createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate.")}), + want: "variation creation requires --model-config-key without interactive input", + }, + "name without content": { + action: createVariationAction("escalation", synccreate.Input{Name: pointer("Escalation")}), + want: "--name needs --content. add creates a variation only when --content is set.", + }, + "blank content": { + action: createVariationAction("escalation", synccreate.Input{Content: pointer("\t"), ModelConfigKey: pointer("claude")}), + want: "--content cannot be blank", + }, + "positional selector": { + action: syncprompt.AddAction{Resources: []syncdomain.ResourceID{escalationID}, Create: &valid}, + want: "a variation that add creates comes from --project, --config-key, and --key, " + + "not from project-key/config-key/variation-key arguments", + }, + "two keys": { + action: syncprompt.AddAction{ + Filter: syncdomain.Filter{Project: "production", ConfigKey: "support", Keys: []string{"escalation", "handoff"}}, + Create: &valid, + }, + want: "add creates one variation at a time. Pass at most one --key.", + }, + "invalid key": { + action: createVariationAction("escalate now", valid), + want: `variation key "escalate now" must use only letters, digits, ".", "_", and "-", ` + + "and start with a letter or digit", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := initRepository(t) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{claude}} + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: test.action, Yes: true}) + + require.EqualError(t, err, test.want) + assert.Empty(t, api.requests) + }) + } +} + +func TestCreateVariationReadsTheContentBeforeTheModelConfig(t *testing.T) { + root := initRepository(t) + api := &directAPI{configMode: syncdomain.VariationModeCompletion, modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Be brief."), ModelConfigKey: pointer("claude")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.EqualError(t, err, "--content: unclosed tag") + assert.Equal(t, []string{"GET https://example.test/api/v2/projects/production/ai-configs/support"}, api.requests) +} + +func TestCreateVariationNeedsAModelConfigThatExists(t *testing.T) { + root := initRepository(t) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{claude}} + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("llama")}) + + _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.EqualError(t, err, `model config "llama" was not found`) + requireOnlyReads(t, api.requests) + assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) +} + +func TestCreateVariationRemovesTheFileWhenLaunchDarklyRejectsTheCreate(t *testing.T) { + root := initRepository(t) + api := &directAPI{ + modelConfigs: []syncapi.ModelConfig{claude}, + createErr: errors.New(`{"code":"invalid_request","message":"instructions are too long","statusCode":400}`), + } + action := createVariationAction("escalation", synccreate.Input{Content: pointer("Escalate."), ModelConfigKey: pointer("claude")}) + + _, notes, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopePrompts, Action: action, Yes: true}) + + require.ErrorContains(t, err, "instructions are too long") + assert.Equal(t, "Note: Removed .launchdarkly/production/"+escalationFile+".\n", notes) + assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) +} + +// createVariationAction is the add that creates the variation key in the +// config "support" of the project "production". +func createVariationAction(key string, input synccreate.Input) syncprompt.AddAction { + return syncprompt.AddAction{ + Filter: syncdomain.Filter{Project: "production", ConfigKey: "support", Keys: []string{key}}, Create: &input, + } +}