mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-06 06:24:04 +00:00
Post-review cleanup of Phase 4. Closes Finding #9 properly and corrects the Finding #13 documentation lie surfaced in the code-review report. Capability schema: - Replace `AgentOverridable bool` with `AgentOverridableAs string` on ParamSchema. Empty string = not overridable; non-empty = the generic key alias (`"speed"`, `"emotion"`, `"style"`). - Each provider declaration now carries the alias inline, so the generic↔native mapping has a single TS-readable source. Frontend: - Web `tts-override-block.tsx` drops the inline `GENERIC_TO_NATIVE` literal and derives the bidirectional adapter from the filtered capability params (each param self-describes its alias). Adapter tests rewritten around the new shape. - Desktop `AgentDetailPanel.tsx` drops the 45-line inline IIFE in favour of a new `<TtsOverrideFineTune>` component that uses the same alias-based mapping. Backend: - Move `AgentTTSParamsAllowedKeys` + `ValidateAgentTTSParams` to `internal/audio/agent_params_adapter.go`. HTTP `validate.go` and WS `gateway/methods/agents_update.go` both delegate, eliminating the duplicated `{speed, emotion, style}` literal. Cleanup: - Delete orphan i18n keys `MsgTtsParamInvalidJSON` and `MsgTtsParamDependsOn` from `keys.go` + en/vi/zh catalogs (no in-code references; DependsOn is FE-only, JSON parse failures already surface via slog). Documentation: - `prompt-settings-section.tsx` Finding #13 comment rewritten to honestly describe the best-effort merge into a fresh local copy of the cached `otherConfig` prop. Concurrent-tab clobber remains possible — server-side JSON-merge-patch endpoint planned for v2. Tests: 9 backend suites (race), web 217/217, desktop build clean, both Go build tags pass.
188 lines
8.1 KiB
Go
188 lines
8.1 KiB
Go
package audio_test
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
|
||
"github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
|
||
oaiprovider "github.com/nextlevelbuilder/goclaw/internal/audio/openai"
|
||
)
|
||
|
||
// TestAdaptAgentParams_TableDriven covers all 5 providers × 3 generic keys
|
||
// plus edge cases (empty input, unknown provider, partial keys).
|
||
func TestAdaptAgentParams_TableDriven(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
generic map[string]any
|
||
provider string
|
||
want map[string]any // nil means expect nil or empty map
|
||
}{
|
||
// --- openai ---
|
||
{name: "openai/speed", generic: map[string]any{"speed": 1.5}, provider: "openai", want: map[string]any{"speed": 1.5}},
|
||
{name: "openai/emotion dropped", generic: map[string]any{"emotion": "happy"}, provider: "openai", want: nil},
|
||
{name: "openai/style dropped", generic: map[string]any{"style": 0.8}, provider: "openai", want: nil},
|
||
|
||
// --- elevenlabs ---
|
||
{name: "elevenlabs/speed → voice_settings.speed", generic: map[string]any{"speed": 1.1}, provider: "elevenlabs", want: map[string]any{"voice_settings.speed": 1.1}},
|
||
{name: "elevenlabs/style → voice_settings.style", generic: map[string]any{"style": 0.5}, provider: "elevenlabs", want: map[string]any{"voice_settings.style": 0.5}},
|
||
{name: "elevenlabs/emotion dropped", generic: map[string]any{"emotion": "sad"}, provider: "elevenlabs", want: nil},
|
||
|
||
// --- edge ---
|
||
{name: "edge/speed dropped", generic: map[string]any{"speed": 1.0}, provider: "edge", want: nil},
|
||
{name: "edge/emotion dropped", generic: map[string]any{"emotion": "angry"}, provider: "edge", want: nil},
|
||
{name: "edge/style dropped", generic: map[string]any{"style": 0.3}, provider: "edge", want: nil},
|
||
|
||
// --- minimax ---
|
||
{name: "minimax/speed", generic: map[string]any{"speed": 1.2}, provider: "minimax", want: map[string]any{"speed": 1.2}},
|
||
{name: "minimax/emotion", generic: map[string]any{"emotion": "happy"}, provider: "minimax", want: map[string]any{"emotion": "happy"}},
|
||
{name: "minimax/style dropped", generic: map[string]any{"style": 0.7}, provider: "minimax", want: nil},
|
||
|
||
// --- gemini ---
|
||
{name: "gemini/speed dropped", generic: map[string]any{"speed": 1.0}, provider: "gemini", want: nil},
|
||
{name: "gemini/emotion dropped", generic: map[string]any{"emotion": "excited"}, provider: "gemini", want: nil},
|
||
{name: "gemini/style dropped", generic: map[string]any{"style": 0.4}, provider: "gemini", want: nil},
|
||
|
||
// --- edge cases ---
|
||
{name: "empty generic map", generic: map[string]any{}, provider: "minimax", want: nil},
|
||
{name: "nil generic map", generic: nil, provider: "minimax", want: nil},
|
||
{name: "unknown provider returns nil", generic: map[string]any{"speed": 1.0}, provider: "azure", want: nil},
|
||
|
||
// --- multi-key cases ---
|
||
{name: "minimax speed+emotion both map", generic: map[string]any{"speed": 0.9, "emotion": "neutral"}, provider: "minimax", want: map[string]any{"speed": 0.9, "emotion": "neutral"}},
|
||
{name: "elevenlabs speed+style both map", generic: map[string]any{"speed": 1.0, "style": 0.3}, provider: "elevenlabs", want: map[string]any{"voice_settings.speed": 1.0, "voice_settings.style": 0.3}},
|
||
{name: "minimax all 3 keys — style dropped", generic: map[string]any{"speed": 1.1, "emotion": "happy", "style": 0.5}, provider: "minimax", want: map[string]any{"speed": 1.1, "emotion": "happy"}},
|
||
|
||
// Critical Finding #1: fallback scenario — ElevenLabs native keys are NOT forwarded to MiniMax
|
||
{name: "Finding#1 no cross-provider bleed: voice_settings.speed not a generic key", generic: map[string]any{"speed": 1.1}, provider: "minimax", want: map[string]any{"speed": 1.1}},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
got := audio.AdaptAgentParams(tc.generic, tc.provider)
|
||
|
||
if tc.want == nil {
|
||
if len(got) > 0 {
|
||
t.Errorf("expected nil/empty map, got %v", got)
|
||
}
|
||
return
|
||
}
|
||
|
||
if len(got) != len(tc.want) {
|
||
t.Errorf("map length mismatch: want %d keys %v, got %d keys %v", len(tc.want), tc.want, len(got), got)
|
||
return
|
||
}
|
||
for wk, wv := range tc.want {
|
||
if gv, ok := got[wk]; !ok {
|
||
t.Errorf("missing key %q in result %v", wk, got)
|
||
} else if gv != wv {
|
||
t.Errorf("key %q: want %v (%T), got %v (%T)", wk, wv, wv, gv, gv)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAdaptAgentParams_Finding9_CrossCheck verifies that every provider with
|
||
// at least one AdaptAgentParams mapping also has at least one ParamSchema with
|
||
// non-empty AgentOverridableAs in its capabilities, AND vice versa.
|
||
// This is the compile-guard drift test mandated by red-team Finding #9.
|
||
//
|
||
// The new AgentOverridableAs field (string) replaces AgentOverridable (bool) to
|
||
// encode the generic key alias directly in the capability schema — eliminating
|
||
// the separate hard-coded genericToNative lookup tables in each UI.
|
||
func TestAdaptAgentParams_Finding9_CrossCheck(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// Providers that the adapter switch handles (non-skip branches).
|
||
// Each must have ≥1 ParamSchema with non-empty AgentOverridableAs in capabilities.
|
||
overridableProviders := map[string]struct {
|
||
caps []audio.ParamSchema
|
||
adapterMap map[string]any // any non-nil output proves mapping exists
|
||
}{
|
||
"openai": {
|
||
caps: buildOpenAICaps(),
|
||
adapterMap: audio.AdaptAgentParams(map[string]any{"speed": 1.0}, "openai"),
|
||
},
|
||
"elevenlabs": {
|
||
caps: buildElevenLabsCaps(),
|
||
adapterMap: audio.AdaptAgentParams(map[string]any{"speed": 1.0}, "elevenlabs"),
|
||
},
|
||
"minimax": {
|
||
caps: buildMiniMaxCaps(),
|
||
adapterMap: audio.AdaptAgentParams(map[string]any{"speed": 1.0}, "minimax"),
|
||
},
|
||
}
|
||
|
||
for providerName, info := range overridableProviders {
|
||
// 1. Adapter must produce output for this provider.
|
||
if len(info.adapterMap) == 0 {
|
||
t.Errorf("provider %q: AdaptAgentParams returned empty map — adapter switch has no active branches", providerName)
|
||
}
|
||
|
||
// 2. Capabilities must have at least one param with non-empty AgentOverridableAs.
|
||
hasOverridable := false
|
||
for _, p := range info.caps {
|
||
if p.AgentOverridableAs != "" {
|
||
hasOverridable = true
|
||
break
|
||
}
|
||
}
|
||
if !hasOverridable {
|
||
t.Errorf("provider %q: no ParamSchema with non-empty AgentOverridableAs in capabilities", providerName)
|
||
}
|
||
}
|
||
|
||
// 3. Cross-check: every (nativeKey, genericAlias) pair in capabilities must
|
||
// be reflected in the adapter switch. AdaptAgentParams(generic=genericAlias)
|
||
// for this provider must map to nativeKey in the output.
|
||
for providerName, info := range overridableProviders {
|
||
for _, p := range info.caps {
|
||
if p.AgentOverridableAs == "" {
|
||
continue
|
||
}
|
||
genericKey := p.AgentOverridableAs
|
||
nativeKey := p.Key
|
||
result := audio.AdaptAgentParams(map[string]any{genericKey: "sentinel"}, providerName)
|
||
if result == nil {
|
||
t.Errorf("provider %q param %q (generic %q): AdaptAgentParams returned nil — adapter switch missing branch",
|
||
providerName, nativeKey, genericKey)
|
||
continue
|
||
}
|
||
if _, ok := result[nativeKey]; !ok {
|
||
t.Errorf("provider %q param %q (generic %q): adapter output %v does not contain expected native key",
|
||
providerName, nativeKey, genericKey, result)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Providers that are skip-only (edge, gemini) must return nil from adapter.
|
||
for _, skipProvider := range []string{"edge", "gemini"} {
|
||
out := audio.AdaptAgentParams(map[string]any{"speed": 1.0, "emotion": "happy", "style": 0.5}, skipProvider)
|
||
if len(out) > 0 {
|
||
t.Errorf("provider %q should produce no output (skip-only) but got %v", skipProvider, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
// buildOpenAICaps returns the params slice from the real OpenAI capabilities.
|
||
func buildOpenAICaps() []audio.ParamSchema {
|
||
p := oaiprovider.NewProvider(oaiprovider.Config{})
|
||
return p.Capabilities().Params
|
||
}
|
||
|
||
// buildElevenLabsCaps returns the params slice from the real ElevenLabs capabilities.
|
||
func buildElevenLabsCaps() []audio.ParamSchema {
|
||
p := elevenlabs.NewTTSProvider(elevenlabs.Config{})
|
||
return p.Capabilities().Params
|
||
}
|
||
|
||
// buildMiniMaxCaps returns the params slice from the real MiniMax capabilities.
|
||
func buildMiniMaxCaps() []audio.ParamSchema {
|
||
p := minimax.NewProvider(minimax.Config{})
|
||
return p.Capabilities().Params
|
||
}
|