Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions cmd/sync/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ var linkExamples = map[syncdomain.Kind]string{
--select 'agents[id="support"]' \
--map prompt=instructions \
--project production --config-key support --key default \
--yes --no-input

# Create the config and the variation when LaunchDarkly has none
ldcli sync prompts link \
--file app/agents.py \
--format python \
--select 'agents[id="support"]' \
--map prompt=instructions \
--project production --config-key support --key default \
--upsert --model-config-key gpt-5 \
--yes --no-input`,
syncdomain.KindTool: ` # Link one TypeScript MCP tool
ldcli sync tools link \
Expand Down Expand Up @@ -52,7 +62,7 @@ func newLinkCmd(client resources.Client, kind syncdomain.Kind) *cobra.Command {
Short: fmt.Sprintf("Link a repository source to one %s", noun),
Long: fmt.Sprintf("Link a repository source to one %s. "+
"Link reads only the named file, shows the descriptor and the sync plan, and writes the descriptor after you confirm. "+
"It then syncs the %s.", noun, noun),
"It then syncs the %s. With --upsert, it creates the %s in LaunchDarkly when it does not exist, and the config of a variation too.", noun, noun, noun),
Example: linkExamples[kind],
Args: validatedArgs(cobra.NoArgs),
RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
Expand All @@ -64,12 +74,16 @@ func newLinkCmd(client resources.Client, kind syncdomain.Kind) *cobra.Command {
if err != nil {
return nil, err
}
upsert, _ := cmd.Flags().GetBool(upsertFlag)
return syncprompt.SourceLinkAction{
ID: id,
File: mustString(cmd, fileFlag),
Format: mustString(cmd, formatFlag),
Select: mustString(cmd, selectFlag),
Map: fieldMap,
ID: id,
File: mustString(cmd, fileFlag),
Format: mustString(cmd, formatFlag),
Select: mustString(cmd, selectFlag),
Map: fieldMap,
Upsert: upsert,
ModelConfigKey: mustString(cmd, modelConfigKeyFlag),
Mode: syncdomain.VariationMode(mustString(cmd, modeFlag)),
}, nil
}),
}
Expand All @@ -78,6 +92,11 @@ func newLinkCmd(client resources.Client, kind syncdomain.Kind) *cobra.Command {
cmd.Flags().String(formatFlag, "", "Source format, for example python, typescript, markdown, or json")
cmd.Flags().String(selectFlag, "", "Source value to select, for example 'agents[id=\"support\"]'")
cmd.Flags().StringArray(mapFlag, nil, "Mapped field in field=path form. Repeat for more fields.")
cmd.Flags().Bool(upsertFlag, false, fmt.Sprintf("Create the %s in LaunchDarkly when it does not exist", noun))
if kind == syncdomain.KindVariation {
cmd.Flags().String(modelConfigKeyFlag, "", "Model config of a variation that --upsert creates. The create uses its latest version.")
cmd.Flags().String(modeFlag, "", "Mode of a config that --upsert creates: agent or completion. The default comes from the source.")
}
_ = cmd.MarkFlagRequired(fileFlag)
_ = cmd.MarkFlagRequired(formatFlag)
addApplyFlags(cmd, scope, false)
Expand Down
2 changes: 2 additions & 0 deletions cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ const (
formatFlag = "format"
jsonSchemaFlag = "json-schema"
keyFlag = "key"
modeFlag = "mode"
modelConfigKeyFlag = "model-config-key"
nameFlag = "name"
noInputFlag = "no-input"
resolveFlag = "resolve"
selectFlag = "select"
mapFlag = "map"
toFlag = "to"
upsertFlag = "upsert"
yesFlag = "yes"
)

Expand Down
23 changes: 22 additions & 1 deletion cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) {
link, _, err := command.Find([]string{"link"})
require.NoError(t, err)
assert.Nil(t, link.Flags().Lookup(toFlag))
assert.Nil(t, link.Flags().Lookup(modelConfigKeyFlag))
assert.NotNil(t, link.Flags().Lookup(upsertFlag))
assert.NotNil(t, link.Flags().Lookup(modelConfigKeyFlag), "a link with --upsert can name the model config of the new variation")
assert.NotNil(t, link.Flags().Lookup(modeFlag))
toolLink := newLinkCmd(nil, syncdomain.KindTool)
assert.NotNil(t, toolLink.Flags().Lookup(upsertFlag))
assert.Nil(t, toolLink.Flags().Lookup(modelConfigKeyFlag), "a tool has no model config")
assert.Nil(t, toolLink.Flags().Lookup(modeFlag))
}

func TestSourceLinkFlagsBuildTargetAndMappings(t *testing.T) {
Expand All @@ -57,6 +63,21 @@ func TestSourceLinkFlagsBuildTargetAndMappings(t *testing.T) {
assert.Equal(t, map[string]string{"prompt": "instructions"}, mappings)
}

func TestSourceLinkFlagsCarryTheUpsertIntent(t *testing.T) {
command := newLinkCmd(nil, syncdomain.KindVariation)
require.NoError(t, command.ParseFlags([]string{
"--file", "agents.py", "--format", "python", "--select", `agents[id="support"]`,
"--project", "production", "--config-key", "support", "--key", "default",
"--upsert", "--model-config-key", "gpt-5", "--mode", "completion",
}))

upsert, err := command.Flags().GetBool(upsertFlag)
require.NoError(t, err)
assert.True(t, upsert)
assert.Equal(t, "gpt-5", mustString(command, modelConfigKeyFlag))
assert.Equal(t, "completion", mustString(command, modeFlag))
}

func TestSyncProblemRendersStableFields(t *testing.T) {
problem := syncdomain.NewProblem(
syncdomain.ErrorPathNotFound,
Expand Down
8 changes: 4 additions & 4 deletions cmd/sync/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,18 @@ var syncCommandFlags = map[string][]string{
"sync prompts detach": {"archive", "config-key", "key", "no-input", "project", "yes"},
"sync prompts link": {
"config-key", "conflict", "diff", "file", "format", "key", "map",
"no-input", "project", "resolve", "select", "yes",
"mode", "model-config-key", "no-input", "project", "resolve", "select", "upsert", "yes",
},
"sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
"sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
"sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"},
"sync tools detach": {"key", "no-input", "project", "yes"},
"sync tools link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"},
"sync tools link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "upsert", "yes"},
"sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
"sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
"sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"},
"sync skills detach": {"key", "no-input", "project", "yes"},
"sync skills link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"},
"sync skills link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "upsert", "yes"},
}

func TestSyncCommandsHaveExactFlags(t *testing.T) {
Expand Down Expand Up @@ -72,7 +72,7 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) {
}
for _, name := range []string{
"config-key", "conflict", "content", "description", "file", "format", "json-schema", "key",
"map", "model-config-key", "name", "resolve", "select", "to",
"map", "mode", "model-config-key", "name", "resolve", "select", "to",
} {
t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "from-environment")
}
Expand Down
16 changes: 16 additions & 0 deletions internal/sync/api/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"

Expand Down Expand Up @@ -115,6 +116,21 @@ func (client Client) Config(projectKey, configKey string) (Config, error) {
return config, nil
}

// CreateConfig creates an AI Config with no variations. A sync creates one
// before the first variation of a linked source when the user asks for it.
func (client Client) CreateConfig(projectKey string, config Config) error {
request := mutation{
method: http.MethodPost, action: "create", resource: "config", key: config.Key,
body: struct {
Key string `json:"key"`
Name string `json:"name"`
Mode syncdomain.VariationMode `json:"mode"`
}{Key: config.Key, Name: config.Name, Mode: config.Mode},
}
_, err := client.mutate(request, projectPath(projectKey, "ai-configs")...)
return err
}

// ModelConfig returns the latest version of one model config.
func (client Client) ModelConfig(projectKey, modelConfigKey string) (ModelConfig, error) {
response, err := client.read(
Expand Down
15 changes: 15 additions & 0 deletions internal/sync/api/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -322,3 +323,17 @@ func mustCatalogJSON(t *testing.T, value any) []byte {

return data
}

func TestClientCreatesConfig(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{"key":"support","name":"Support","mode":"agent","variations":[]}`)}}
client := NewClient(transport, "token", "https://example.com")

err := client.CreateConfig("project", Config{Key: "support", Name: "Support", Mode: syncdomain.VariationModeAgent})

require.NoError(t, err)
require.Len(t, transport.Requests, 1)
request := transport.Requests[0]
assert.Equal(t, http.MethodPost, request.Method)
assert.Equal(t, "https://example.com/api/v2/projects/project/ai-configs", request.Path)
assert.JSONEq(t, `{"key":"support","name":"Support","mode":"agent"}`, string(request.Body))
}
167 changes: 158 additions & 9 deletions internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ type directAPI struct {
variation *syncdomain.Variation
variationState string
// 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
directions map[string]syncdomain.SyncDirection
requests []string
configMode syncdomain.VariationMode
// configMissing makes every read of the config "support" a 404 until a
// create of the config, as a project without the config does.
configMissing bool
modelConfigs []syncapi.ModelConfig
tools map[string]versionedTool
skills map[string]versionedSkill
manifest *syncapi.SyncManifest
directions map[string]syncdomain.SyncDirection
requests []string
// expandTools makes each read of the variation add the content of its
// tools to model.parameters.tools, as LaunchDarkly does.
expandTools bool
Expand Down Expand Up @@ -199,6 +202,24 @@ func (api *directAPI) MakeRequest(
}
return nil, fmt.Errorf("model config not found")
}
if method == http.MethodPost && strings.HasSuffix(path, "/ai-configs") {
var request struct {
Key string `json:"key"`
Name string `json:"name"`
Mode syncdomain.VariationMode `json:"mode"`
}
if err := json.Unmarshal(body, &request); err != nil {
return nil, err
}
if !api.configMissing {
return nil, fmt.Errorf(`{"code":"conflict","message":"AI config already exists","statusCode":409}`)
}
api.configMissing, api.configMode = false, request.Mode
return json.Marshal(map[string]any{"key": request.Key, "name": request.Name, "mode": request.Mode, "variations": []any{}})
}
if api.configMissing {
return nil, fmt.Errorf(`{"code":"not_found","message":"AI config not found","statusCode":404}`)
}

switch method {
case "GET":
Expand Down Expand Up @@ -2051,6 +2072,135 @@ support = Agent(instructions="Server instructions after link.")
`, string(actual))
}

// A link with --upsert creates the config and the variation that
// LaunchDarkly does not have, in one run, from the source alone. The config
// takes the mode that the source implies and the name of its key.
func TestLinkUpsertCreatesTheConfigAndTheVariation(t *testing.T) {
root := initRepository(t)
source := []byte("from agents import Agent\nsupport = Agent(instructions=\"Local instructions.\")\n")
require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), source, 0o644))
api := &directAPI{configMissing: true}
id := syncdomain.VariationID("production", "support", "default")

output, _, err := runScoped(t, root, api, syncprompt.Options{
Scope: syncdomain.ScopePrompts,
Action: syncprompt.SourceLinkAction{
ID: id, File: "agents.py", Format: "python", Select: `agents[id="support"]`,
Map: map[string]string{"prompt": "instructions"}, Upsert: true,
},
Yes: true,
})

require.NoError(t, err)
assert.Contains(t, output, `"action": "create_server"`)
assert.Equal(t, 1, countRequests(api.requests, "POST", "/ai-configs"), "the config is created once")
assert.False(t, api.configMissing)
assert.Equal(t, syncdomain.VariationModeAgent, api.configMode, "a text prompt makes an agent config")
require.NotNil(t, api.variation)
assert.Equal(t, "Local instructions.", api.variation.Instructions)
assert.Equal(t, "Default", api.variation.Name, "the name comes from the key")
require.FileExists(t, filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml"))
lock, err := os.ReadFile(filepath.Join(root, ".launchdarkly", "sync.lock"))
require.NoError(t, err)
assert.Contains(t, string(lock), "key: support/default", "the lock records the created variation")
actual, err := os.ReadFile(filepath.Join(root, "agents.py"))
require.NoError(t, err)
assert.Equal(t, source, actual, "the source is not changed")
}

// --model-config-key gives the created variation a model config at its
// latest version, as an add does. An existing config is not created again.
func TestLinkUpsertCreatesTheVariationWithAModelConfig(t *testing.T) {
root := initRepository(t)
require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644))
api := &directAPI{modelConfigs: []syncapi.ModelConfig{{Key: "gpt-5", Name: "GPT-5", Version: 3}}}
id := syncdomain.VariationID("production", "support", "default")

output, _, err := runScoped(t, root, api, syncprompt.Options{
Scope: syncdomain.ScopePrompts,
Action: syncprompt.SourceLinkAction{
ID: id, File: "prompt.md", Format: "markdown", Upsert: true, ModelConfigKey: "gpt-5",
},
Yes: true,
})

require.NoError(t, err)
assert.Contains(t, output, `"action": "create_server"`)
assert.Zero(t, countRequests(api.requests, "POST", "/ai-configs"), "the config exists")
require.NotNil(t, api.variation)
assert.Equal(t, "gpt-5", api.variation.ModelConfigKey)
assert.Equal(t, 3, api.variation.ModelConfigVersion)
assert.Equal(t, "Local instructions.", api.variation.Instructions)
}

// --mode decides the mode of a config that --upsert creates. A text prompt
// becomes the one system message of a completion variation.
func TestLinkUpsertModeOverridesTheSourceMode(t *testing.T) {
root := initRepository(t)
require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644))
api := &directAPI{configMissing: true}
id := syncdomain.VariationID("production", "support", "default")

_, _, err := runScoped(t, root, api, syncprompt.Options{
Scope: syncdomain.ScopePrompts,
Action: syncprompt.SourceLinkAction{
ID: id, File: "prompt.md", Format: "markdown", Upsert: true, Mode: syncdomain.VariationModeCompletion,
},
Yes: true,
})

require.NoError(t, err)
assert.Equal(t, syncdomain.VariationModeCompletion, api.configMode)
require.NotNil(t, api.variation)
assert.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Local instructions."}}, api.variation.Messages)
}

// Without --upsert, a missing config is a row of the plan that names the
// config and the flag, and it blocks the apply without a write.
func TestLinkWithoutUpsertNamesTheMissingConfig(t *testing.T) {
root := initRepository(t)
require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644))
api := &directAPI{configMissing: true}
id := syncdomain.VariationID("production", "support", "default")

_, _, err := runScoped(t, root, api, syncprompt.Options{
Scope: syncdomain.ScopePrompts,
Action: syncprompt.SourceLinkAction{ID: id, File: "prompt.md", Format: "markdown"},
Yes: true,
})

require.Error(t, err)
assert.Contains(t, err.Error(), `config "support" does not exist in LaunchDarkly`)
assert.Contains(t, err.Error(), "--upsert")
assert.Zero(t, countRequests(api.requests, "POST", "/ai-configs"))
assert.True(t, api.configMissing)
assert.NoFileExists(t, filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml"), "a blocked link writes no descriptor")
}

// A link of a tool with --upsert creates the tool that LaunchDarkly does not
// have, through the same publish as a managed tool file with upsert.
func TestLinkUpsertCreatesAMissingTool(t *testing.T) {
root := initRepository(t)
require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local","schema":{"type":"object"}}`), 0o644))
api := &directAPI{tools: map[string]versionedTool{}}
id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search"}

output, _, err := runScoped(t, root, api, syncprompt.Options{
Scope: syncdomain.ScopeTools,
Action: syncprompt.SourceLinkAction{
ID: id, File: "tool.json", Format: "json", Select: "$",
Map: map[string]string{"description": "description", "schema": "schema"}, Upsert: true,
},
Yes: true,
})

require.NoError(t, err)
assert.Contains(t, output, `"action": "create_server"`)
require.Contains(t, api.tools, "search")
assert.Equal(t, "Local", *api.tools["search"].Description)
require.FileExists(t, filepath.Join(root, ".launchdarkly", "production", "tools", "search.ref.yaml"))
}

func TestPromptDescriptorTopLevelToolSyncsEndToEnd(t *testing.T) {
root := initRepository(t)
require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644))
Expand Down Expand Up @@ -3489,8 +3639,7 @@ func TestVariationPushDoesNotCreateAServerCanonicalLinkedTool(t *testing.T) {

require.Error(t, err, "the missing linked tool blocks the plan")
assert.Equal(t, []string{"tool search error", "variation support/default update_server"}, planRows(t, stdout))
assert.Contains(t, stdout, `tool \"search\" does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again.`)
assert.NotContains(t, stdout, "upsert", "a descriptor has no upsert, so the message does not ask for one")
assert.Contains(t, stdout, `tool \"search\" does not exist in LaunchDarkly. Run link again with --upsert to create it, or create it in LaunchDarkly and run sync again.`)
_, _, err = runPrompt(t, root, api, "--yes")
require.Error(t, err)
assert.Empty(t, api.tools, "the push does not create the tool")
Expand Down
Loading
Loading