Skip to content
Merged
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
4 changes: 3 additions & 1 deletion internal/sync/api/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ func (config *Config) UnmarshalJSON(data []byte) error {
config.Variations = make([]syncdomain.Variation, 0, len(response.Variations))
for _, variation := range response.Variations {
if variation.State != "archived" {
config.Variations = append(config.Variations, variation.Variation)
canonical := variation.Variation
canonical.Model = syncdomain.CanonicalModel(canonical.Model, canonical.Tools)
config.Variations = append(config.Variations, canonical)
}
}
return nil
Expand Down
35 changes: 35 additions & 0 deletions internal/sync/api/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,41 @@ func TestCatalogClientConfigGetsExactConfigAndAppliesMode(t *testing.T) {
assert.False(t, request.IsBeta)
}

func TestCatalogClientConfigRemovesExpandedToolsFromModelParameters(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"key": "support",
"mode": "agent",
"variations": [{
"key": "helpful",
"name": "Helpful",
"model": {
"modelName": "claude",
"parameters": {
"temperature": 0.2,
"tools": [{"type": "function", "name": "search"}],
"skills": [{"key": "support"}]
}
},
"tools": [{"key": "search", "version": 3}],
"skills": [{"key": "support", "version": 2}]
}]
}`)}}

config, err := NewClient(transport, "token", "https://example.com").Config("project", "support")

require.NoError(t, err)
require.Len(t, config.Variations, 1)
assert.Equal(t, map[string]any{
"modelName": "claude",
"parameters": map[string]any{
"temperature": 0.2,
"skills": []any{map[string]any{"key": "support"}},
},
}, config.Variations[0].Model)
assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search", Version: 3}}, config.Variations[0].Tools)
assert.Equal(t, []syncdomain.AttachmentRef{{Key: "support", Version: 2}}, config.Variations[0].Skills)
}

func TestCatalogClientConfigRejectsInvalidResponse(t *testing.T) {
client := NewClient(
&recordingClient{Responses: [][]byte{[]byte(`not json`)}},
Expand Down
23 changes: 16 additions & 7 deletions internal/sync/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func (variation Variation) canonical() Variation {
variation.Attachments[index] = CanonicalAttachment(variation.Attachments[index])
}
variation.SortAttachments()
variation.Model = canonicalModel(variation.Model)
variation.Model = CanonicalModel(variation.Model, variation.Tools)

switch variation.Mode {
case VariationModeAgent:
Expand Down Expand Up @@ -108,14 +108,23 @@ func unversionedRefs(refs []AttachmentRef) []AttachmentRef {
return unversioned
}

// canonicalModel removes the empty "parameters" and "custom" objects that the
// variation API adds. Other empty objects can change behavior, so they remain.
func canonicalModel(model map[string]any) map[string]any {
// CanonicalModel returns a copy without defaults and expanded tool content
// that the variation API adds. Tool references own that content.
func CanonicalModel(model map[string]any, tools []AttachmentRef) map[string]any {
model = maps.Clone(model)
for _, key := range []string{"parameters", "custom"} {
if value, ok := model[key].(map[string]any); ok && len(value) == 0 {
delete(model, key)
if parameters, ok := model["parameters"].(map[string]any); ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should validate user's input parameters as an object here I think.
In case someone does:

key: default
name: Default
model:
  modelName: gpt-5
  parameters: temperature=0.2
---

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Your example synced without an error, and ldcli sent "parameters": "temperature=0.2" as a string. LaunchDarkly does not keep a non-object value there, so the setting was lost. Variation.Validate now rejects a non-object model.parameters or model.custom with model.parameters must be an object (or model.custom ...) before any write. TestPromptRejectsModelValuesThatAreNotObjects covers your example, an empty parameters:, and a list in custom, and it failed before the fix. Added in f001a10.

parameters = maps.Clone(parameters)
if len(tools) != 0 {
delete(parameters, "tools")
}
if len(parameters) == 0 {
delete(model, "parameters")
} else {
model["parameters"] = parameters
}
}
if custom, ok := model["custom"].(map[string]any); ok && len(custom) == 0 {
delete(model, "custom")
}
if len(model) == 0 {
return nil
Expand Down
58 changes: 58 additions & 0 deletions internal/sync/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,64 @@ func TestFingerprintVariationNormalizesModelDefaults(t *testing.T) {
require.Contains(t, withDefaults.Model, "custom")
}

func TestFingerprintVariationExcludesExpandedToolParameters(t *testing.T) {
base := Variation{
Mode: VariationModeAgent,
Key: "agent",
Name: "Agent",
Model: map[string]any{
"modelName": "claude",
"parameters": map[string]any{
"temperature": 0.2,
},
},
Tools: []AttachmentRef{{Key: "search"}},
}
expanded := base
expanded.Model = map[string]any{
"modelName": "claude",
"parameters": map[string]any{
"temperature": 0.2,
"tools": []any{map[string]any{"type": "function", "name": "search"}},
},
}
changed := expanded
changed.Model = map[string]any{
"modelName": "claude",
"parameters": map[string]any{
"temperature": 0.7,
"tools": []any{map[string]any{"type": "function", "name": "search"}},
},
}

baseFingerprint, err := FingerprintVariation("project", "config/agent", base)
require.NoError(t, err)
expandedFingerprint, err := FingerprintVariation("project", "config/agent", expanded)
require.NoError(t, err)
changedFingerprint, err := FingerprintVariation("project", "config/agent", changed)
require.NoError(t, err)

require.Equal(t, baseFingerprint, expandedFingerprint)
require.NotEqual(t, baseFingerprint, changedFingerprint)
require.Contains(t, expanded.Model["parameters"], "tools")

withoutSkillParameter := base
withoutSkillParameter.Skills = []AttachmentRef{{Key: "support"}}
withSkillParameter := withoutSkillParameter
withSkillParameter.Model = map[string]any{
"modelName": "claude",
"parameters": map[string]any{
"temperature": 0.2,
"skills": []any{map[string]any{"key": "support"}},
},
}
withoutSkillFingerprint, err := FingerprintVariation("project", "config/agent", withoutSkillParameter)
require.NoError(t, err)
withSkillFingerprint, err := FingerprintVariation("project", "config/agent", withSkillParameter)
require.NoError(t, err)
require.NotEqual(t, withoutSkillFingerprint, withSkillFingerprint)
}

func TestFingerprintVariationPreservesMeaningfulModelChanges(t *testing.T) {
base := Variation{
Mode: VariationModeAgent,
Expand Down
169 changes: 168 additions & 1 deletion internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -38,6 +39,9 @@ type directAPI struct {
skills map[string]versionedSkill
manifest *syncapi.SyncManifest
requests []string
// expandTools makes each read of the variation add the content of its
// tools to model.parameters.tools, as LaunchDarkly does.
expandTools bool
}

type notFoundAPI struct{}
Expand Down Expand Up @@ -152,8 +156,12 @@ func (api *directAPI) MakeRequest(
case "GET":
variations := []directAPIVariation{}
if api.variation != nil {
served := *api.variation
if api.expandTools && len(served.Tools) != 0 {
served.Model = api.expandedModel(served)
}
variations = append(variations, directAPIVariation{
Variation: *api.variation,
Variation: served,
State: api.variationState,
})
}
Expand Down Expand Up @@ -222,6 +230,34 @@ func (*directAPI) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, er
return nil, nil
}

// expandedModel returns the model of the variation with the content of each
// of its tools in model.parameters.tools.
func (api *directAPI) expandedModel(variation syncdomain.Variation) map[string]any {
model := maps.Clone(variation.Model)
if model == nil {
model = map[string]any{}
}
parameters, _ := model["parameters"].(map[string]any)
parameters = maps.Clone(parameters)
if parameters == nil {
parameters = map[string]any{}
}
expanded := make([]any, 0, len(variation.Tools))
for _, ref := range variation.Tools {
tool := api.tools[ref.Key]
description := ""
if tool.Description != nil {
description = *tool.Description
}
expanded = append(expanded, map[string]any{
"type": "function", "name": tool.Key, "description": description, "parameters": tool.Schema,
})
}
parameters["tools"] = expanded
model["parameters"] = parameters
return model
}

func TestPromptDryRunUsesOnlyExistingReadAPI(t *testing.T) {
root := initRepository(t)
baseline := variation("Baseline")
Expand Down Expand Up @@ -272,6 +308,137 @@ func TestPromptFirstSyncAdoptsMatchingStateWithoutMutation(t *testing.T) {
requireOnlyReads(t, api.requests)
}

func TestPromptDiffExcludesExpandedToolsFromModelParameters(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to cover only pull. think it would be worth it for a push test case?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, added TestPromptPushExcludesExpandedToolsFromModelParameters. The fake API can now add the expanded tools to model.parameters.tools on every read, as LaunchDarkly does. The test changes only the local instructions and checks that the plan says instructions changed with no model change. It then pushes, checks that LaunchDarkly gets the local model without the expanded tools, and checks that the next sync reports No changes.. Without this PR's fix, the same test fails with a conflict, because the expanded tools look like a model change in LaunchDarkly. Added in 4e0e87b.

root := initRepository(t)
description := "Search the documentation"
tool := syncdomain.Tool{
Key: "search",
Description: &description,
Schema: map[string]any{"type": "object"},
}
local := variation("Support")
local.Instructions = "Use the local instructions."
local.ModelConfigKey = "claude"
local.ModelConfigVersion = 2
local.Model = map[string]any{
"modelName": "claude-sonnet-4",
"parameters": map[string]any{
"temperature": 0.2,
},
}
local.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 3}}
local.Attachments = []syncdomain.Attachment{toolAttachment(tool, 3)}
writeVariation(t, root, local, false)
writeManifest(t, root, local)

server := local
server.Instructions = "Use the LaunchDarkly instructions."
server.Attachments = nil
server.Model = map[string]any{
"modelName": "claude-sonnet-4",
"parameters": map[string]any{
"temperature": 0.2,
"tools": []any{map[string]any{
"type": "function",
"name": "search",
"description": description,
"parameters": map[string]any{"type": "object"},
}},
},
}
api := &directAPI{
variation: &server,
tools: map[string]versionedTool{
"search": {Tool: tool, Version: 3},
},
}

stdout, _, err := runPrompt(t, root, api, "--dry-run", "--diff", "--output", "plaintext")

require.NoError(t, err)
assert.Contains(t, stdout, "instructions changed in LaunchDarkly")
assert.NotContains(t, stdout, "model changed in LaunchDarkly")
assert.NotContains(t, stdout, `"type": "function"`)
assert.NotContains(t, stdout, `Tool "search"`)

_, _, err = runPrompt(t, root, api, "--yes")
require.NoError(t, err)
resources, err := synclocal.CompileWorkspace(root)
require.NoError(t, err)
require.Len(t, resources, 1)
assert.Equal(t, map[string]any{
"modelName": "claude-sonnet-4",
"parameters": map[string]any{
"temperature": 0.2,
},
}, resources[0].Variation.Model)
assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search"}}, resources[0].Variation.Tools)
}

// A push of a local change also ignores the expanded tools that LaunchDarkly
// returns, sends only the local model, and leaves both sides in sync.
func TestPromptPushExcludesExpandedToolsFromModelParameters(t *testing.T) {
root := initRepository(t)
description := "Search the documentation"
tool := syncdomain.Tool{Key: "search", Description: &description, Schema: map[string]any{"type": "object"}}
base := variation("Support")
base.Instructions = "Use the LaunchDarkly instructions."
base.ModelConfigKey = "claude"
base.ModelConfigVersion = 2
base.Model = map[string]any{"modelName": "claude-sonnet-4", "parameters": map[string]any{"temperature": 0.2}}
base.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 3}}
base.Attachments = []syncdomain.Attachment{toolAttachment(tool, 3)}
local := base
local.Instructions = "Use the local instructions."
writeVariation(t, root, local, false)
writeManifest(t, root, base)

server := base
server.Attachments = nil
api := &directAPI{variation: &server, tools: map[string]versionedTool{"search": {Tool: tool, Version: 3}}, expandTools: true}

stdout, _, err := runPrompt(t, root, api, "--dry-run", "--diff", "--output", "plaintext")

require.NoError(t, err)
assert.Contains(t, stdout, "instructions changed")
assert.NotContains(t, stdout, "model changed")
assert.NotContains(t, stdout, `"type": "function"`)

_, _, err = runPrompt(t, root, api, "--yes")
require.NoError(t, err)
assert.Equal(t, local.Instructions, api.variation.Instructions)
assert.Equal(t, base.Model, api.variation.Model, "the push sends the local model without expanded tools")

stdout, _, err = runPrompt(t, root, api, "--dry-run", "--output", "plaintext")
require.NoError(t, err)
assert.Contains(t, stdout, "No changes.")
}

// LaunchDarkly keeps model parameters and custom values only as objects, so
// sync rejects any other value before a write.
func TestPromptRejectsModelValuesThatAreNotObjects(t *testing.T) {
for name, test := range map[string]struct {
model string
want string
}{
"parameters": {" parameters: temperature=0.2\n", "model.parameters must be an object"},
"empty parameters": {" parameters:\n", "model.parameters must be an object"},
"custom": {" custom: [retries]\n", "model.custom must be an object"},
} {
t.Run(name, func(t *testing.T) {
root := initRepository(t)
writeLocalFile(t, root, "configs/support/default.prompt.md", "---\nformatVersion: 1\nupsert: true\n"+
"mode: agent\nkey: default\nname: Default\nmodel:\n modelName: gpt-5\n"+test.model+"---\n\nHelp.\n")
api := &directAPI{}

_, _, err := runPrompt(t, root, api, "--yes")

require.ErrorContains(t, err, test.want)
requireOnlyReads(t, api.requests)
})
}
}

func TestPromptFirstSyncCreatesUpsertVariation(t *testing.T) {
root := initRepository(t)
local := variation("New")
Expand Down
1 change: 1 addition & 0 deletions internal/sync/prompt/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func variationJSON(variation *syncdomain.Variation) json.RawMessage {
return nil
}
withoutRefs := *variation
withoutRefs.Model = syncdomain.CanonicalModel(withoutRefs.Model, withoutRefs.Tools)
withoutRefs.Tools, withoutRefs.Skills = nil, nil
data, _ := json.Marshal(withoutRefs)
return data
Expand Down
Loading
Loading