Improve active skill schema handling

This commit is contained in:
lky-spec
2026-04-26 11:58:29 +08:00
parent 9ee931ee30
commit 903eb591eb
25 changed files with 1401 additions and 673 deletions

View File

@@ -1,11 +1,16 @@
package agent
import (
"encoding/json"
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestClassifyWorkflowTaskTreatsTraderEditAsBindingsOrRename(t *testing.T) {
func TestClassifyWorkflowTaskTreatsTraderEditAsManualPanelUpdate(t *testing.T) {
task, ok := classifyWorkflowTask("帮我把交易员小爱换策略")
if !ok {
t.Fatal("expected trader binding edit to classify")
@@ -14,12 +19,12 @@ func TestClassifyWorkflowTaskTreatsTraderEditAsBindingsOrRename(t *testing.T) {
t.Fatalf("unexpected task: %+v", task)
}
task, ok = classifyWorkflowTask("帮我把交易员小爱改名")
task, ok = classifyWorkflowTask("帮我把交易员小爱扫描间隔改成10分钟")
if !ok {
t.Fatal("expected trader rename to classify")
t.Fatal("expected trader manual-panel edit to classify")
}
if task.Skill != "trader_management" || task.Action != "update_name" {
t.Fatalf("unexpected rename task: %+v", task)
if task.Skill != "trader_management" || task.Action != "update_bindings" {
t.Fatalf("unexpected trader update task: %+v", task)
}
}
@@ -35,3 +40,424 @@ func TestTraderDomainPrimerExplainsInternalConfigBoundary(t *testing.T) {
}
}
}
func TestLoadEnabledModelOptionsUseConfigNameAsPrimaryLabel(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-model-options.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
options := a.loadEnabledModelOptions("default")
if len(options) != 1 {
t.Fatalf("expected one model option, got %d", len(options))
}
if options[0].Name != "DeepSeek AI" {
t.Fatalf("expected primary option label to stay on config name, got %q", options[0].Name)
}
if !strings.Contains(options[0].Hint, "deepseek-chat") || !strings.Contains(options[0].Hint, "deepseek") {
t.Fatalf("expected hint to retain runtime model/provider context, got %q", options[0].Hint)
}
}
func TestHydrateCreateTraderSlotReferencesNormalizesModelIDFromVisibleName(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-model-id-normalize.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
session := skillSession{
Name: "trader_management",
Action: "create",
Fields: map[string]string{
"model_id": "DeepSeek AI",
},
}
a.hydrateCreateTraderSlotReferences("default", &session)
if got := fieldValue(session, "model_id"); got != "default_deepseek" {
t.Fatalf("expected visible model name in model_id slot to normalize to actual id, got %q", got)
}
if got := fieldValue(session, "model_name"); got != "DeepSeek AI" {
t.Fatalf("expected normalized model name to be preserved, got %q", got)
}
}
func TestHydrateCreateTraderSlotReferencesNormalizesExchangeIDFromVisibleName(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-exchange-id-normalize.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
exchangeID, err := st.Exchange().Create("default", "okx", "小偶", true, "api-test", "secret-test", "pass", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
session := skillSession{
Name: "trader_management",
Action: "create",
Fields: map[string]string{
"exchange_id": "小偶",
},
}
a.hydrateCreateTraderSlotReferences("default", &session)
if got := fieldValue(session, "exchange_id"); got != exchangeID {
t.Fatalf("expected visible exchange name in exchange_id slot to normalize to actual id, got %q", got)
}
if got := fieldValue(session, "exchange_name"); got != "小偶" {
t.Fatalf("expected normalized exchange name to be preserved, got %q", got)
}
}
func TestToolDeleteTraderRejectsRunningTrader(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "delete-running-trader.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.Trader().Create(&store.Trader{
ID: "trader-running",
UserID: "default",
Name: "运行中",
AIModelID: "model-1",
ExchangeID: "exchange-1",
InitialBalance: 100,
ScanIntervalMinutes: 3,
IsRunning: true,
}); err != nil {
t.Fatalf("seed trader: %v", err)
}
resp := a.toolDeleteTrader("default", "trader-running")
if !strings.Contains(resp, "stop it before deleting") {
t.Fatalf("expected running trader delete to be rejected, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 {
t.Fatalf("expected running trader to remain, got %d traders", len(traders))
}
}
func TestBulkTraderDeleteDeletesOnlyStoppedTraders(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "bulk-delete-traders.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
for _, trader := range []*store.Trader{
{ID: "trader-stopped", UserID: "default", Name: "已停止", AIModelID: "model-1", ExchangeID: "exchange-1", InitialBalance: 100, ScanIntervalMinutes: 3, IsRunning: false},
{ID: "trader-running", UserID: "default", Name: "运行中", AIModelID: "model-1", ExchangeID: "exchange-1", InitialBalance: 100, ScanIntervalMinutes: 3, IsRunning: true},
} {
if err := st.Trader().Create(trader); err != nil {
t.Fatalf("seed trader %s: %v", trader.ID, err)
}
}
session := skillSession{
Name: "trader_management",
Action: "delete",
Phase: "await_confirmation",
Fields: map[string]string{
"bulk_scope": "all",
skillDAGStepField: "await_confirmation",
},
}
resp := a.executeBulkTraderDelete("default", 99, "zh", "确认", session)
if !strings.Contains(resp, "成功删除 1 个") || !strings.Contains(resp, "运行中") {
t.Fatalf("expected stopped trader deleted and running trader skipped, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 || traders[0].ID != "trader-running" {
t.Fatalf("expected only running trader to remain, got: %+v", traders)
}
}
func TestBulkTraderDeleteRequiresConfirmationBeforeDeleting(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "bulk-delete-traders-confirmation.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.Trader().Create(&store.Trader{
ID: "trader-stopped",
UserID: "default",
Name: "已停止",
AIModelID: "model-1",
ExchangeID: "exchange-1",
InitialBalance: 100,
ScanIntervalMinutes: 3,
IsRunning: false,
}); err != nil {
t.Fatalf("seed trader: %v", err)
}
session := skillSession{
Name: "trader_management",
Action: "delete",
Fields: map[string]string{
"bulk_scope": "all",
},
}
resp := a.executeBulkTraderDelete("default", 99, "zh", "全部删除", session)
if !strings.Contains(resp, "请回复“确认”继续") {
t.Fatalf("expected confirmation prompt, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 {
t.Fatalf("expected trader to remain before confirmation, got %d traders", len(traders))
}
}
func TestResolveTargetSelectionMatchesUniqueNameInUserText(t *testing.T) {
options := []traderSkillOption{
{ID: "exchange-a", Name: "okx"},
{ID: "exchange-b", Name: "为:小易"},
{ID: "exchange-c", Name: "小偶"},
}
resolved := resolveTargetSelection("先把 为:小易 删掉,其他 5 个先保留", options, nil)
if resolved.Ref == nil {
t.Fatal("expected target ref to resolve from user text")
}
if resolved.Ref.ID != "exchange-b" || resolved.Ref.Name != "为:小易" {
t.Fatalf("unexpected resolved target: %+v", resolved.Ref)
}
}
func TestBulkStrategyDeleteRequiresConfirmationBeforeDeleting(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "bulk-delete-strategies-confirmation.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-custom",
UserID: "default",
Name: "自定义策略",
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
session := skillSession{
Name: "strategy_management",
Action: "delete",
Fields: map[string]string{
"bulk_scope": "all",
},
}
resp := a.executeStrategyManagementAction("default", 99, "zh", "全部删除", session)
if !strings.Contains(resp, "请回复“确认”继续") {
t.Fatalf("expected confirmation prompt, got: %s", resp)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
found := false
for _, strategy := range strategies {
if strategy.ID == "strategy-custom" {
found = true
}
}
if !found {
t.Fatal("expected strategy to remain before confirmation")
}
}
func TestEnsureLiveTargetReferenceFallsBackFromStaleIDToName(t *testing.T) {
session := skillSession{
TargetRef: &EntityReference{
ID: "stale-id",
Name: "小易",
},
}
options := []traderSkillOption{
{ID: "exchange-a", Name: "okx"},
{ID: "exchange-b", Name: "为:小易"},
}
if !ensureLiveTargetReference(&session, options) {
t.Fatal("expected stale id with matching name to resolve")
}
if session.TargetRef == nil || session.TargetRef.ID != "exchange-b" || session.TargetRef.Name != "为:小易" {
t.Fatalf("unexpected target ref after live check: %+v", session.TargetRef)
}
}
func TestBuildTraderCreateMissingPromptListsAllMissingSlots(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-create-missing-prompt.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "okx", "OKX 主账户", true, "api-test", "secret-test", "pass", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
_ = exchangeID
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-ai500",
UserID: "default",
Name: "AI500稳重策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
session := skillSession{
Name: "trader_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{},
}
prompt := a.buildTraderCreateMissingPrompt("default", "zh", session, a.buildTraderCreateConversationResources("default", session))
for _, want := range []string{"名称", "交易所", "模型", "策略"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected missing prompt to include %q, got: %s", want, prompt)
}
}
for _, want := range []string{"现有交易所", "现有模型", "现有策略"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected missing prompt to include options line %q, got: %s", want, prompt)
}
}
}
func TestPlannerContextModeFollowsRouterContextSwitch(t *testing.T) {
if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{ContextSwitch: true}); got != "fresh_context" {
t.Fatalf("expected fresh context mode, got %q", got)
}
if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{}); got != "" {
t.Fatalf("expected default context mode, got %q", got)
}
}
func TestLLMFlowExtractionFiltersFieldsToAllowedSchema(t *testing.T) {
result := llmFlowExtractionResult{
Intent: "continue",
Tasks: []llmFlowExtractionTask{{
Skill: "exchange_management",
Action: "create",
Fields: map[string]string{
"secret": "wrong-key",
"secret_key": "canonical-secret",
"api_key": "api",
},
}},
}
filtered := filterLLMFlowExtractionFields(result, []llmFlowFieldSpec{
{Key: "secret_key"},
{Key: "api_key"},
})
fields := filtered.Tasks[0].Fields
if _, ok := fields["secret"]; ok {
t.Fatalf("expected invented field key to be filtered, got: %+v", fields)
}
if fields["secret_key"] != "canonical-secret" || fields["api_key"] != "api" {
t.Fatalf("expected canonical fields to remain, got: %+v", fields)
}
}
func TestExchangeCreateAllowedFieldSpecsUseCanonicalSecretKey(t *testing.T) {
specs := allowedFieldSpecsForSkillSession(skillSession{Name: "exchange_management", Action: "create"}, "zh")
foundSecretKey := false
for _, spec := range specs {
if spec.Key == "secret" {
t.Fatal("exchange create schema should not expose non-canonical secret key")
}
if spec.Key == "secret_key" {
foundSecretKey = true
}
}
if !foundSecretKey {
t.Fatal("expected exchange create schema to include canonical secret_key")
}
}
func TestActiveSessionExtractedDataFiltersToAllowedSchema(t *testing.T) {
session := ActiveSkillSession{
SkillName: "exchange_management",
ActionName: "create",
CollectedFields: map[string]any{
"exchange_type": "okx",
},
}
filtered := filterExtractedDataForActiveSession(session, map[string]any{
"account_name": "呢呢",
"api_key": "api",
"secret": "wrong-key",
"secret_key": "canonical-secret",
"passphrase": "pass",
}, "zh")
if _, ok := filtered["secret"]; ok {
t.Fatalf("expected central brain alias key to be filtered, got: %+v", filtered)
}
for _, key := range []string{"account_name", "api_key", "secret_key", "passphrase"} {
if _, ok := filtered[key]; !ok {
t.Fatalf("expected canonical key %q to remain, got: %+v", key, filtered)
}
}
}
func TestBrainUserPromptIncludesActiveAllowedFieldSchema(t *testing.T) {
prompt := buildBrainUserPrompt(
"zh",
"密钥是abc123456",
"要创建交易所配置还缺这些字段Secret。",
"",
"",
ActiveSkillSession{SkillName: "exchange_management", ActionName: "create"},
true,
)
if !strings.Contains(prompt, "allowed_field_spec_json") || !strings.Contains(prompt, `"secret_key"`) {
t.Fatalf("expected brain prompt to expose canonical field schema, got:\n%s", prompt)
}
}