Skip to content
Open
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 {
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
67 changes: 67 additions & 0 deletions internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,73 @@ func TestPromptFirstSyncAdoptsMatchingStateWithoutMutation(t *testing.T) {
requireOnlyReads(t, api.requests)
}

func TestPromptDiffExcludesExpandedToolsFromModelParameters(t *testing.T) {
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)
}

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
31 changes: 31 additions & 0 deletions internal/sync/prompt/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,37 @@ func TestBuildPlanOmitsDiffForEquivalentAPIDefaults(t *testing.T) {
require.Empty(t, plan.Resources[0].Diff)
}

func TestBuildPlanKeepsRealModelParameterChangesWithExpandedTools(t *testing.T) {
local := testVariation("example")
local.Tools = []syncdomain.AttachmentRef{{Key: "search"}}
local.Model = map[string]any{
"modelName": "gpt-5",
"parameters": map[string]any{
"temperature": 0.2,
},
}
server := local
server.Model = map[string]any{
"modelName": "gpt-5",
"parameters": map[string]any{
"temperature": 0.7,
"tools": []any{map[string]any{"type": "function", "name": "search"}},
},
}

id := testResourceID()
manifest := syncmanifest.New()
manifest.SetFingerprint(id, fingerprintOf(t, local))
plan := BuildPlan(manifest, localResources(&local, false), map[ResourceID]ServerResource{
id: {Variation: &server, ConfigMode: syncdomain.VariationModeAgent},
})

require.Equal(t, ActionUpdateLocal, plan.Resources[0].Action)
require.Equal(t, "model changed in LaunchDarkly", planReason(plan.Resources[0]))
require.NotContains(t, string(plan.Resources[0].Diff["variation"].Before), `"tools"`)
require.Contains(t, string(plan.Resources[0].Diff["variation"].Before), `"temperature":0.7`)
}

func TestBuildPlanRejectsParentConfigModeMismatch(t *testing.T) {
local := testVariation("local")
id := testResourceID()
Expand Down
Loading