Files
nofx/agent/trader_scope_test.go
tinklefund f4ee723aa2 feat(agent): surface Hyperliquid stock trading context
- Add stock symbol panel and agent chat page wiring

- Update onboarding and tool visibility for focused trader flows

- Tighten related tests around configuration and trader scope
2026-05-25 01:25:10 +08:00

2028 lines
71 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package agent
import (
"context"
"encoding/json"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"nofx/mcp"
"nofx/store"
)
type staticAIClient struct {
response string
lastRequest *mcp.Request
}
func (c *staticAIClient) SetAPIKey(apiKey string, customURL string, customModel string) {}
func (c *staticAIClient) SetTimeout(timeout time.Duration) {}
func (c *staticAIClient) CallWithMessages(systemPrompt, userPrompt string) (string, error) {
return c.response, nil
}
func (c *staticAIClient) CallWithRequest(req *mcp.Request) (string, error) {
c.lastRequest = req
return c.response, nil
}
func (c *staticAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
c.lastRequest = req
if onChunk != nil {
onChunk(c.response)
}
return c.response, nil
}
func (c *staticAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
c.lastRequest = req
return &mcp.LLMResponse{Content: c.response}, nil
}
func TestClassifyWorkflowTaskTreatsTraderEditAsManualPanelUpdate(t *testing.T) {
task, ok := classifyWorkflowTask("帮我把交易员小爱换策略")
if !ok {
t.Fatal("expected trader binding edit to classify")
}
if task.Skill != "trader_management" || task.Action != "update_bindings" {
t.Fatalf("unexpected task: %+v", task)
}
task, ok = classifyWorkflowTask("帮我把交易员小爱扫描间隔改成10分钟")
if !ok {
t.Fatal("expected trader manual-panel edit to classify")
}
if task.Skill != "trader_management" || task.Action != "update_bindings" {
t.Fatalf("unexpected trader update task: %+v", task)
}
}
func TestGetDecisionsToolReturnsRecentTraderDecisionEvidence(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "decision-evidence.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
traderCfg := &store.Trader{
ID: "trader-claw402",
UserID: "default",
Name: "claw402",
AIModelID: "model-1",
ExchangeID: "exchange-1",
InitialBalance: 6.21,
ScanIntervalMinutes: 3,
IsRunning: true,
}
if err := st.Trader().Create(traderCfg); err != nil {
t.Fatalf("seed trader: %v", err)
}
if err := st.Decision().LogDecision(&store.DecisionRecord{
TraderID: traderCfg.ID,
CycleNumber: 150,
Timestamp: time.Now().Add(-3 * time.Minute),
Success: true,
AIRequestDurationMs: 12095,
CandidateCoins: []string{"BTCUSDT"},
ExecutionLog: []string{"AI call duration: 12095 ms", "✓ BTCUSDT wait succeeded"},
Decisions: []store.DecisionAction{{
Symbol: "BTCUSDT",
Action: "wait",
Success: true,
}},
}); err != nil {
t.Fatalf("seed wait decision: %v", err)
}
if err := st.Decision().LogDecision(&store.DecisionRecord{
TraderID: traderCfg.ID,
CycleNumber: 151,
Timestamp: time.Now(),
Success: false,
ErrorMessage: "Failed to get AI decision: failed to parse AI response: decision validation failed: decision #1 validation failed: BTCUSDT opening amount too small (28.00 USDT), must be ≥60.00 USDT",
AIRequestDurationMs: 25878,
CandidateCoins: []string{"BTCUSDT"},
ExecutionLog: []string{"AI call duration: 25878 ms"},
DecisionJSON: `[{"symbol":"BTCUSDT","action":"open_short","position_size_usd":28}]`,
}); err != nil {
t.Fatalf("seed rejected decision: %v", err)
}
raw := a.toolGetDecisions("default", `{"trader_name":"claw402","limit":2}`)
for _, want := range []string{"claw402", "BTCUSDT", "wait", "wait succeeded", "opening amount too small", "must be ≥60.00 USDT"} {
if !strings.Contains(raw, want) {
t.Fatalf("expected decision evidence %q in tool response, got: %s", want, raw)
}
}
}
func TestTraderDiagnosisReadsDecisionsInsteadOfAskingUserForScreenshot(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-diagnosis-decisions.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
traderCfg := &store.Trader{
ID: "trader-claw402",
UserID: "default",
Name: "claw402",
AIModelID: "model-1",
ExchangeID: "exchange-1",
InitialBalance: 6.21,
ScanIntervalMinutes: 3,
IsRunning: true,
}
if err := st.Trader().Create(traderCfg); err != nil {
t.Fatalf("seed trader: %v", err)
}
if err := st.Decision().LogDecision(&store.DecisionRecord{
TraderID: traderCfg.ID,
CycleNumber: 1,
Timestamp: time.Now(),
Success: true,
AIRequestDurationMs: 13249,
CandidateCoins: []string{"BTCUSDT"},
ExecutionLog: []string{"AI call duration: 13249 ms", "✓ BTCUSDT wait succeeded"},
Decisions: []store.DecisionAction{{
Symbol: "BTCUSDT",
Action: "wait",
Success: true,
}},
}); err != nil {
t.Fatalf("seed decision: %v", err)
}
reply := a.handleTraderDiagnosisSkill("default", "zh", "为什么我的claw402交易员一直不开单呢")
for _, want := range []string{"claw402 是运行的", "主动选择等待", "入场标准", "该怎么办"} {
if !strings.Contains(reply, want) {
t.Fatalf("expected diagnosis to include %q, got: %s", want, reply)
}
}
for _, unexpected := range []string{"截图", "自己点", "不能直接帮你查", "诊断证据包", "AI 调用耗时", "status 402", "404", "EOF", "订阅"} {
if strings.Contains(reply, unexpected) {
t.Fatalf("diagnosis should not ask user to self-serve %q, got: %s", unexpected, reply)
}
}
}
func TestTraderDiagnosisAmountTooSmallUsesUserFacingCauseAndAction(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-diagnosis-amount-too-small.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
traderCfg := &store.Trader{
ID: "trader-claw402",
UserID: "default",
Name: "claw402",
AIModelID: "model-1",
ExchangeID: "exchange-1",
InitialBalance: 6.21,
ScanIntervalMinutes: 3,
IsRunning: true,
}
if err := st.Trader().Create(traderCfg); err != nil {
t.Fatalf("seed trader: %v", err)
}
if err := st.Decision().LogDecision(&store.DecisionRecord{
TraderID: traderCfg.ID,
CycleNumber: 2,
Timestamp: time.Now(),
Success: false,
ErrorMessage: "Failed to get AI decision: failed to parse AI response: decision validation failed: decision #1 validation failed: BTCUSDT opening amount too small (28.00 USDT), must be ≥60.00 USDT",
AIRequestDurationMs: 25878,
CandidateCoins: []string{"BTCUSDT"},
ExecutionLog: []string{"AI call duration: 25878 ms"},
DecisionJSON: `[{"symbol":"BTCUSDT","action":"open_short","position_size_usd":28}]`,
}); err != nil {
t.Fatalf("seed decision: %v", err)
}
reply := a.handleTraderDiagnosisSkill("default", "zh", "为什么我的claw402交易员一直不开单呢")
for _, want := range []string{"不是没运行", "账户资金太小", "开仓金额约 28.00 USDT", "最小下单要求 60.00 USDT", "增加账户资金", "不能手动修改"} {
if !strings.Contains(reply, want) {
t.Fatalf("expected diagnosis to include %q, got: %s", want, reply)
}
}
for _, unexpected := range []string{"诊断证据包", "辅助异常", "status 402", "404", "EOF", "订阅", "数据服务", "position_size_usd", "AI 调用耗时"} {
if strings.Contains(reply, unexpected) {
t.Fatalf("diagnosis should stay user-facing and avoid %q, got: %s", unexpected, reply)
}
}
}
func TestTraderDiagnosisUsesLLMToReasonOverCollectedEvidence(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-diagnosis-llm.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
llm := &staticAIClient{response: "claw402 的最终原因是账户资金太小,最近想开 BTCUSDT 空单但金额低于最小下单要求。该怎么办:增加账户资金,或换更适合小资金的策略/标的。"}
a := New(nil, st, DefaultConfig(), slog.Default())
a.SetAIClient(llm)
traderCfg := &store.Trader{
ID: "trader-claw402",
UserID: "default",
Name: "claw402",
AIModelID: "model-1",
ExchangeID: "exchange-1",
InitialBalance: 6.21,
ScanIntervalMinutes: 3,
IsRunning: true,
}
if err := st.Trader().Create(traderCfg); err != nil {
t.Fatalf("seed trader: %v", err)
}
if err := st.Decision().LogDecision(&store.DecisionRecord{
TraderID: traderCfg.ID,
CycleNumber: 3,
Timestamp: time.Now(),
Success: false,
ErrorMessage: "BTCUSDT opening amount too small (28.00 USDT), must be ≥60.00 USDT",
CandidateCoins: []string{"BTCUSDT"},
DecisionJSON: `[{"symbol":"BTCUSDT","action":"open_short","position_size_usd":28}]`,
}); err != nil {
t.Fatalf("seed decision: %v", err)
}
reply := a.handleTraderDiagnosisSkill("default", "zh", "为什么我的claw402交易员一直不开单呢")
if reply != llm.response {
t.Fatalf("expected LLM diagnosis response, got: %s", reply)
}
if llm.lastRequest == nil || len(llm.lastRequest.Messages) < 2 {
t.Fatalf("expected LLM request to be captured")
}
prompt := llm.lastRequest.Messages[1].Content
for _, want := range []string{"Evidence JSON", "claw402", "BTCUSDT", "opening amount too small", "decision_json"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected LLM evidence prompt to include %q, got: %s", want, prompt)
}
}
}
func TestTraderDomainPrimerExplainsInternalConfigBoundary(t *testing.T) {
primer := buildSkillDomainPrimer("zh", "trader_management")
for _, want := range []string{
"交易员是装配层",
"默认只处理绑定关系",
"应切到对应 management skill",
} {
if !strings.Contains(primer, want) {
t.Fatalf("expected primer to contain %q, got: %s", want, primer)
}
}
}
func TestStrategyDomainPrimerKeepsSourceCountsWithinEditorBounds(t *testing.T) {
primer := buildSkillDomainPrimerForSession("zh", skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"strategy_type": "ai_trading",
},
})
for _, want := range []string{
"AI500/OI Top/OI Low 选币数量范围 110",
"没有 mixed/混合模式",
"BTC/ETH 最大杠杆 120",
"min_confidence 50100",
} {
if !strings.Contains(primer, want) {
t.Fatalf("expected primer to contain %q, got: %s", want, primer)
}
}
}
func TestStrategyConfigSchemaOnlyExposesEditorCoinSourceFields(t *testing.T) {
schema := strategyConfigSchema()
properties := schema["properties"].(map[string]any)
aiConfig := properties["ai_config"].(map[string]any)
aiProperties := aiConfig["properties"].(map[string]any)
coinSource := aiProperties["coin_source"].(map[string]any)
coinProperties := coinSource["properties"].(map[string]any)
for _, unexpected := range []string{"use_hyper_all", "use_hyper_main", "hyper_main_limit"} {
if _, ok := coinProperties[unexpected]; ok {
t.Fatalf("strategy config schema should not expose non-editor coin source field %s", unexpected)
}
}
ai500 := coinProperties["ai500_limit"].(map[string]any)
if ai500["maximum"] != 10 {
t.Fatalf("expected AI500 maximum 10, got %+v", ai500)
}
}
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, 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 TestStrategyUpdateUsesExplicitTargetOverCurrentReference(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-explicit-target-over-current.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
userID := int64(99)
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
for _, strategy := range []*store.Strategy{
{ID: "strategy-short", UserID: "default", Name: "BTC趋势做空", ConfigVisible: true, Config: string(rawCfg)},
{ID: "strategy-long", UserID: "default", Name: "AI500 做多策略", ConfigVisible: true, Config: string(rawCfg)},
} {
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("seed strategy %s: %v", strategy.ID, err)
}
}
a.saveReferenceMemory(userID, &CurrentReferences{
Strategy: &EntityReference{ID: "strategy-short", Name: "BTC趋势做空", Source: "tool_output"},
}, nil)
patch := map[string]any{
"coin_source": map[string]any{
"source_type": "ai500",
"use_ai500": true,
"ai500_limit": 5,
},
"custom_prompt": "AI500 强做多策略:只寻找强趋势多头机会。",
}
rawPatch, _ := json.Marshal(patch)
session := skillSession{
Name: "strategy_management",
Action: "update_config",
Phase: "collecting",
Fields: map[string]string{strategyCreateConfigPatchField: string(rawPatch)},
}
reply, handled := a.handleSimpleEntitySkill(
"default",
userID,
"zh",
"我想基于AI500 做多策略来调整成更强的做多逻辑",
session,
"strategy_management",
"update_config",
a.loadStrategyOptions("default"),
)
if !handled {
t.Fatalf("expected handler to handle request")
}
if !strings.Contains(reply, "已更新策略配置") {
t.Fatalf("expected strategy update reply, got: %s", reply)
}
shortStrategy, err := st.Strategy().Get("default", "strategy-short")
if err != nil {
t.Fatalf("load short strategy: %v", err)
}
longStrategy, err := st.Strategy().Get("default", "strategy-long")
if err != nil {
t.Fatalf("load long strategy: %v", err)
}
if strings.Contains(shortStrategy.Config, "强做多") {
t.Fatalf("current reference strategy was incorrectly updated: %s", shortStrategy.Config)
}
if !strings.Contains(longStrategy.Config, "强做多") {
t.Fatalf("explicitly named strategy was not updated: %s", longStrategy.Config)
}
}
func TestStrategyUpdateDoesNotInferTargetFromCurrentReference(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-no-current-reference-fallback.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
userID := int64(100)
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-short",
UserID: "default",
Name: "BTC趋势做空",
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
a.saveReferenceMemory(userID, &CurrentReferences{
Strategy: &EntityReference{ID: "strategy-short", Name: "BTC趋势做空", Source: "tool_output"},
}, nil)
patch := map[string]any{"custom_prompt": "不应被写入"}
rawPatch, _ := json.Marshal(patch)
session := skillSession{
Name: "strategy_management",
Action: "update_config",
Phase: "collecting",
Fields: map[string]string{strategyCreateConfigPatchField: string(rawPatch)},
}
reply, handled := a.handleSimpleEntitySkill(
"default",
userID,
"zh",
"帮我把策略改强一点",
session,
"strategy_management",
"update_config",
a.loadStrategyOptions("default"),
)
if !handled {
t.Fatalf("expected handler to ask for target")
}
if !strings.Contains(reply, "确定目标对象") && !strings.Contains(reply, "明确要操作的是哪一个对象") {
t.Fatalf("expected target clarification, got: %s", reply)
}
strategy, err := st.Strategy().Get("default", "strategy-short")
if err != nil {
t.Fatalf("load strategy: %v", err)
}
if strings.Contains(strategy.Config, "不应被写入") {
t.Fatalf("strategy was incorrectly updated through current reference fallback: %s", strategy.Config)
}
}
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, 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 TestTraderCreateRequiresResolvedResourceIDs(t *testing.T) {
session := skillSession{
Name: "trader_management",
Action: "create",
Fields: map[string]string{
"name": "凯茵",
"exchange_name": "Binance",
"model_name": "deepseek",
"strategy_name": "BTC趋势做空",
},
}
missing := missingFieldKeysForSkillSession(session)
for _, want := range []string{"exchange_name", "model_name", "strategy_name"} {
if !containsString(missing, want) {
t.Fatalf("expected unresolved %s to remain missing, got %v", want, missing)
}
}
active := ActiveSkillSession{
SkillName: "trader_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "凯茵",
"exchange_name": "Binance",
"model_name": "deepseek",
"strategy_name": "BTC趋势做空",
},
}
activeMissing := missingRequiredFields(active)
for _, want := range []string{"exchange", "model", "strategy"} {
if !containsString(activeMissing, want) {
t.Fatalf("expected unresolved active slot %s to remain missing, got %v", want, activeMissing)
}
}
}
func TestStrategyCreateUsesConfigPatch(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-config-patch.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
patch := map[string]any{
"strategy_type": "ai_trading",
"coin_source": map[string]any{
"source_type": "static",
"static_coins": []any{"BTCUSDT"},
"use_ai500": false,
"use_oi_low": true,
"oi_low_limit": 1,
},
"risk_control": map[string]any{
"max_positions": 1,
"btc_eth_max_leverage": 5,
"altcoin_max_leverage": 5,
"min_confidence": 80,
"min_risk_reward_ratio": 3,
},
"indicators": map[string]any{
"klines": map[string]any{
"primary_timeframe": "5m",
"selected_timeframes": []any{"5m", "15m"},
},
},
"prompt_sections": map[string]any{
"trading_frequency": "每天最多 2-4 笔,避免过度交易。",
"entry_standards": "只在 BTC 下跌趋势确认时考虑做空,禁止把做多作为主方向。",
},
"custom_prompt": "BTC 趋势做空策略:仅关注 BTCUSDT趋势向下且反弹受阻时才考虑开空。",
}
rawPatch, _ := json.Marshal(patch)
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "BTC趋势做空",
strategyCreateConfigPatchField: string(rawPatch),
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session)
if !strings.Contains(reply, "已创建策略") {
t.Fatalf("expected created reply, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
var created *store.Strategy
for _, strategy := range strategies {
if strategy.Name == "BTC趋势做空" {
created = strategy
break
}
}
if created == nil {
t.Fatalf("expected strategy to be created")
}
var cfg store.StrategyConfig
if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
if cfg.CoinSource.SourceType != "static" || len(cfg.CoinSource.StaticCoins) != 1 || cfg.CoinSource.StaticCoins[0] != "BTCUSDT" {
t.Fatalf("expected BTC static coin source, got %+v", cfg.CoinSource)
}
if cfg.CoinSource.UseAI500 {
t.Fatalf("expected AI500 disabled for explicit BTC strategy")
}
if cfg.CoinSource.UseOILow {
t.Fatalf("expected OI low disabled when source_type is static, got %+v", cfg.CoinSource)
}
if cfg.RiskControl.MaxPositions != 3 || cfg.RiskControl.MinConfidence != 80 {
t.Fatalf("expected risk patch to apply, got %+v", cfg.RiskControl)
}
if !strings.Contains(cfg.CustomPrompt, "BTC 趋势做空") || !strings.Contains(cfg.PromptSections.EntryStandards, "做空") {
t.Fatalf("expected prompt patch to apply, got custom=%q entry=%q", cfg.CustomPrompt, cfg.PromptSections.EntryStandards)
}
}
func TestAIStrategySystemEnforcedFieldsAreDisplayedButNotEditable(t *testing.T) {
cfg := store.GetDefaultStrategyConfig("zh")
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "我的AI策略",
},
}
reply := formatStrategyCreateFinalConfirmation("zh", session, cfg)
for _, want := range []string{"最大持仓数System enforced", "BTC/ETH 单币仓位上限System enforced", "最大保证金使用率System enforced", "最小开仓金额System enforced"} {
if !strings.Contains(reply, want) {
t.Fatalf("expected final summary to display %q, got: %s", want, reply)
}
}
resp := applyStrategyConfigPatch(&cfg, "max_margin_usage", "0.5")
if resp == nil || !strings.Contains(resp.Error(), "System enforced") {
t.Fatalf("expected system enforced edit to be rejected, got: %v", resp)
}
}
func TestStrategyCreateNaturalLanguageDoesNotBypassTemplateType(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-draft-two-turn.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
active := ActiveSkillSession{
SessionID: "as_test",
UserID: 1,
SkillName: "strategy_management",
ActionName: "create",
Goal: "真的去创建一个趋势策略交易BTC和ETH15m杠杆 5 倍",
CollectedFields: map[string]any{
"name": "BTCETH_15m_趋势",
},
LocalHistory: []chatMessage{
{Role: "user", Content: "真的去创建一个趋势策略交易BTC和ETH15m杠杆 5 倍"},
{Role: "assistant", Content: "现在只差一个名称。"},
{Role: "user", Content: "BTCETH_15m_趋势"},
},
}
session := activeToLegacySkillSession(active)
reply := a.handleStrategyCreateSkill("default", 1, "zh", "BTCETH_15m_趋势", session)
if !strings.Contains(reply, "先选择策略类型") {
t.Fatalf("expected strategy type question instead of legacy natural-language parsing, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
if len(strategies) != 0 {
t.Fatalf("expected no strategy before template is complete, got %d", len(strategies))
}
}
func TestStrategyCreateAsksTypeBeforeUsingDefaultTemplateType(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-ask-type.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "我的策略",
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "我的策略", session)
if !strings.Contains(reply, "先选择策略类型") || strings.Contains(reply, "交易所") {
t.Fatalf("expected strategy type question without exchange binding, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
for _, strategy := range strategies {
if strategy.Name == "我的策略" {
t.Fatalf("strategy should not be created before type is confirmed")
}
}
}
func TestStrategyCreateConfirmationStillRequiresType(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-confirm-no-type.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "我的策略",
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session)
if !strings.Contains(reply, "先选择策略类型") {
t.Fatalf("expected type question before create, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
for _, strategy := range strategies {
if strategy.Name == "我的策略" {
t.Fatalf("strategy should not be created before type is known")
}
}
}
func TestStrategyCreateStandaloneNameCanContainStrategyWord(t *testing.T) {
active := ActiveSkillSession{
SessionID: "as_test",
UserID: 1,
SkillName: "strategy_management",
ActionName: "create",
Goal: "创建一个趋势策略交易BTC和ETH15m杠杆 5 倍",
CollectedFields: map[string]any{},
LocalHistory: []chatMessage{
{Role: "user", Content: "创建一个趋势策略交易BTC和ETH15m杠杆 5 倍"},
{Role: "assistant", Content: "现在只差一个名称。"},
{Role: "user", Content: "趋势策略A"},
},
}
session := activeToLegacySkillSession(active)
if got := fieldValue(session, "name"); got != "趋势策略A" {
t.Fatalf("expected standalone strategy name to be preserved, got %q", got)
}
}
func TestStrategyCreateProposesGridDefaultsBeforeCreate(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-draft.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "我的网格策略",
"strategy_type": "grid_trading",
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "grid_trading", session)
if !strings.Contains(reply, "还缺") || !strings.Contains(reply, "交易对") || !strings.Contains(reply, "网格数量") {
t.Fatalf("expected grid template missing-fields prompt, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
for _, strategy := range strategies {
if strategy.Name == "我的网格策略" {
t.Fatalf("strategy should not be created before grid config is ready")
}
}
}
func TestStrategyCreateSwitchingTypeDropsPreviousTemplateFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-switch-type.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
aiPatch := map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{"source_type": "ai500"},
"risk_control": map[string]any{
"min_confidence": 80,
"min_risk_reward_ratio": 3,
},
},
}
rawPatch, _ := json.Marshal(aiPatch)
session := skillSession{
Name: "strategy_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{
"name": "我的网格大大",
"strategy_type": "ai_trading",
strategyCreateConfigPatchField: string(rawPatch),
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "算了选网格策略吧", session)
if !strings.Contains(reply, "还缺") || !strings.Contains(reply, "交易对") {
t.Fatalf("expected grid missing fields after type switch, got: %s", reply)
}
if strings.Contains(reply, "AI500") || strings.Contains(reply, "置信度") {
t.Fatalf("type switch should not reuse AI fields or default BTC summary, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
for _, strategy := range strategies {
if strategy.Name == "我的网格大大" {
t.Fatalf("strategy should not be created after switching type with missing grid fields")
}
}
}
func TestActiveStrategyCreateFilterIsolatesTemplateOnTypeSwitch(t *testing.T) {
session := ActiveSkillSession{
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "我的网格大大",
"strategy_type": "ai_trading",
strategyCreateConfigPatchField: map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{"source_type": "ai500"},
},
},
},
}
filtered := filterExtractedDataForActiveSession(session, map[string]any{
"strategy_type": "grid_trading",
strategyCreateConfigPatchField: map[string]any{
"strategy_type": "grid_trading",
"grid_config": map[string]any{"symbol": "ETHUSDT"},
"ai_config": map[string]any{"coin_source": map[string]any{"source_type": "ai500"}},
},
}, "zh")
mergeExtractedData(&session, filtered)
if got := session.CollectedFields["strategy_type"]; got != "grid_trading" {
t.Fatalf("expected switched strategy type, got %+v", session.CollectedFields)
}
if _, ok := session.CollectedFields["source_type"]; ok {
t.Fatalf("expected AI-only flat fields to be dropped, got %+v", session.CollectedFields)
}
patch := session.CollectedFields[strategyCreateConfigPatchField].(map[string]any)
if _, ok := patch["ai_config"]; ok {
t.Fatalf("expected ai_config to be removed from grid patch, got %+v", patch)
}
if _, ok := patch["grid_config"]; !ok {
t.Fatalf("expected grid_config to remain, got %+v", patch)
}
}
func TestStrategyCreateConfirmationFillsMissingGridDefaults(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-confirm-defaults.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "餐巾纸",
"strategy_type": "grid_trading",
"symbol": "BTCUSDT",
"awaiting_final_confirmation": "true",
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "好的,就这样", session)
if !strings.Contains(reply, "还缺") || strings.Contains(reply, "已创建策略") {
t.Fatalf("expected missing grid fields instead of default create, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
for _, strategy := range strategies {
if strategy.Name == "餐巾纸" {
t.Fatalf("strategy should not be created before grid template is complete")
}
}
}
func TestStrategyCreateGridDraftSummaryDoesNotMentionAIFields(t *testing.T) {
reply := formatStrategyCreateDraftSummary("zh", "我的网格策略", "grid_trading", nil, nil)
for _, unexpected := range []string{"选币来源", "最大持仓", "置信度", "盈亏比", "多周期"} {
if strings.Contains(reply, unexpected) {
t.Fatalf("grid draft summary should not mention AI-only field %q: %s", unexpected, reply)
}
}
for _, expected := range []string{"网格策略", "交易对", "网格数量", "总投入", "杠杆", "价格区间"} {
if !strings.Contains(reply, expected) {
t.Fatalf("grid draft summary should mention %q, got: %s", expected, reply)
}
}
}
func TestAllowedStrategyCreateFieldsUseConfigPatchOnly(t *testing.T) {
gridSession := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"strategy_type": "grid_trading",
},
}
gridSpecs := allowedFieldSpecsForSkillSession(gridSession, "zh")
gridKeys := make(map[string]bool, len(gridSpecs))
for _, spec := range gridSpecs {
gridKeys[spec.Key] = true
}
for _, expected := range []string{"strategy_type", "name", strategyCreateConfigPatchField, "awaiting_final_confirmation"} {
if !gridKeys[expected] {
t.Fatalf("expected grid field %q in specs", expected)
}
}
for _, unexpected := range []string{"symbol", "grid_count", "total_investment", "source_type", "selected_timeframes", "min_confidence", "min_risk_reward_ratio"} {
if gridKeys[unexpected] {
t.Fatalf("strategy create specs should not expose template field %q outside config_patch", unexpected)
}
}
}
func TestStrategyCreateReadyConfigRequiresFinalConfirmation(t *testing.T) {
patch := map[string]any{
"strategy_type": "grid_trading",
"grid_config": map[string]any{
"symbol": "BTCUSDT",
"grid_count": 20,
"total_investment": 200,
"leverage": 2,
"use_atr_bounds": true,
"atr_multiplier": 2,
"distribution": "uniform",
"max_drawdown_pct": 15,
"stop_loss_pct": 8,
"daily_loss_limit_pct": 6,
"use_maker_only": true,
"enable_direction_adjust": false,
},
}
rawPatch, _ := json.Marshal(patch)
session := ActiveSkillSession{
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "小白策略",
"strategy_type": "grid_trading",
strategyCreateConfigPatchField: string(rawPatch),
},
}
reply, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session)
if !blocked {
t.Fatalf("expected ready strategy create config to require final confirmation")
}
if !strings.Contains(reply, "确认后我再创建") || !strings.Contains(reply, "BTCUSDT") || !strings.Contains(reply, "20") {
t.Fatalf("expected final confirmation summary, got: %s", reply)
}
session.CollectedFields["awaiting_final_confirmation"] = true
if _, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session); !blocked {
t.Fatalf("same-turn awaiting flag without prior assistant confirmation should still be blocked")
}
session.LocalHistory = append(session.LocalHistory, chatMessage{Role: "assistant", Content: reply})
if _, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session); blocked {
t.Fatalf("already-confirmable session should not be blocked")
}
}
func TestStrategyCreateConfirmationForcesSynchronousExecutionRoute(t *testing.T) {
patch := map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{
"source_type": "ai500",
"use_ai500": true,
"ai500_limit": 5,
},
"indicators": map[string]any{
"klines": map[string]any{
"primary_timeframe": "1m",
"selected_timeframes": []any{"1m", "5m"},
},
},
"risk_control": map[string]any{
"btc_eth_max_leverage": 3,
"altcoin_max_leverage": 2,
"min_confidence": 70,
"min_risk_reward_ratio": 1.5,
},
"prompt_sections": map[string]any{
"trading_frequency": "高频交易但避免过度交易。",
"entry_standards": "只在短周期趋势明确且风险收益合理时开仓。",
},
},
}
rawPatch, _ := json.Marshal(patch)
session := ActiveSkillSession{
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "AI500高频交易",
"strategy_type": "ai_trading",
strategyCreateConfigPatchField: string(rawPatch),
},
LocalHistory: []chatMessage{
{Role: "assistant", Content: "请确认是否按以上设置创建?如果没问题,我就执行创建。"},
},
}
for _, confirmation := range []string{"确认创建", "可以", "好的", "没问题", "ok"} {
t.Run(confirmation, func(t *testing.T) {
sessionCopy := session
sessionCopy.CollectedFields = map[string]any{
"name": "AI500高频交易",
"strategy_type": "ai_trading",
strategyCreateConfigPatchField: string(rawPatch),
}
decision := activeSessionStepDecision{
Route: "ask_user",
Reply: "好的正在为你创建“AI500高频交易”策略……",
}
if !maybeForceStrategyCreateExecutionOnConfirmation("zh", confirmation, &sessionCopy, &decision) {
t.Fatalf("expected confirmation %q to force execute route", confirmation)
}
if decision.Route != "execute_skill" || decision.Reply != "" {
t.Fatalf("expected synchronous execute route with empty reply, got %+v", decision)
}
if !activeFieldBool(sessionCopy.CollectedFields["awaiting_final_confirmation"]) {
t.Fatalf("expected awaiting_final_confirmation to be set before execution")
}
})
}
}
func TestStrategyCreateConfirmationForcesExecutionWithoutPriorPromptPhrase(t *testing.T) {
patch := map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{
"source_type": "ai500",
"use_ai500": true,
"ai500_limit": 5,
},
"indicators": map[string]any{
"klines": map[string]any{
"primary_timeframe": "3m",
"selected_timeframes": []any{"3m", "5m", "15m"},
},
},
"risk_control": map[string]any{
"btc_eth_max_leverage": 3,
"altcoin_max_leverage": 2,
"min_confidence": 75,
"min_risk_reward_ratio": 1.5,
},
"prompt_sections": map[string]any{
"trading_frequency": "高频但避免过度交易。",
"entry_standards": "趋势明确、成交量配合、风险收益合理才开仓。",
},
},
}
rawPatch, _ := json.Marshal(patch)
session := ActiveSkillSession{
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "高频稳健AI500",
"strategy_type": "ai_trading",
strategyCreateConfigPatchField: string(rawPatch),
},
LocalHistory: []chatMessage{
{Role: "assistant", Content: "这是我建议的一版配置。"},
},
}
decision := activeSessionStepDecision{
Route: "ask_user",
Reply: "好的马上为你创建“高频稳健AI500”策略。",
}
if !maybeForceStrategyCreateExecutionOnConfirmation("zh", "确认创建", &session, &decision) {
t.Fatalf("expected ready strategy confirmation to force execute even without prior prompt phrase")
}
if decision.Route != "execute_skill" || decision.Reply != "" {
t.Fatalf("expected execute route, got %+v", decision)
}
}
func TestUnifiedPlannedAgentCannotStealActiveStrategyCreateConfirmation(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-planner-steal.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"好的,马上为你创建策略。","extracted_data":{}}`})
patch := map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{
"source_type": "ai500",
"use_ai500": true,
"ai500_limit": 5,
},
"indicators": map[string]any{
"klines": map[string]any{
"primary_timeframe": "5m",
"selected_timeframes": []any{"1m", "5m", "15m"},
},
},
"risk_control": map[string]any{
"btc_eth_max_leverage": 3,
"altcoin_max_leverage": 2,
"min_confidence": 80,
"min_risk_reward_ratio": 1.5,
},
"prompt_sections": map[string]any{
"trading_frequency": "每天最多 5-8 笔,避免连续亏损后追单。",
"entry_standards": "趋势确认、成交量放大、资金费率正常才开仓。",
},
},
}
rawPatch, _ := json.Marshal(patch)
userID := int64(42)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.CollectedFields = map[string]any{
"name": "AI500高频",
"strategy_type": "ai_trading",
"awaiting_final_confirmation": true,
strategyCreateConfigPatchField: string(rawPatch),
}
a.saveActiveSkillSession(session)
decision := unifiedTurnDecision{
TopicIntent: "continue_active",
BusinessAction: "planned_agent",
}
reply, handled, err := a.executeUnifiedTurnDecision(context.Background(), "default", userID, "zh", "确认", decision, nil)
if err != nil {
t.Fatalf("execute unified turn: %v", err)
}
if !handled {
t.Fatalf("expected turn to be handled")
}
if strings.Contains(reply, "马上") || strings.Contains(reply, "稍后") || strings.Contains(reply, "正在") {
t.Fatalf("expected planner promise to be bypassed, got: %s", reply)
}
if !strings.Contains(reply, "已创建策略") {
t.Fatalf("expected real strategy creation result, got: %s", reply)
}
}
func TestStrategyCreateRepairPromiseIsNotReturnedOnConfirmation(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-repair-promise.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"好的马上为你创建AI500高频稳健策略。","extracted_data":{}}`})
userID := int64(42)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.CollectedFields = map[string]any{
"name": "AI500高频",
"strategy_type": "ai_trading",
}
session.LocalHistory = []chatMessage{
{Role: "assistant", Content: "如果你确认没问题,告诉我“确认创建”,我就帮你直接创建。"},
}
a.saveActiveSkillSession(session)
reply, handled, err := a.driveActiveSession(context.Background(), "default", userID, "zh", "确认创建", session, nil)
if err != nil {
t.Fatalf("drive active session: %v", err)
}
if !handled {
t.Fatalf("expected confirmation turn to be handled")
}
if strings.Contains(reply, "马上") || strings.Contains(reply, "正在") || strings.Contains(reply, "稍后") {
t.Fatalf("repair promise should not be returned on confirmation, got: %s", reply)
}
}
func TestModelCreateSessionRedirectsStrategyTypeChoiceToStrategyCreate(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-type-choice-not-model-provider.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
const userID int64 = 42
a.saveSkillSession(userID, skillSession{
Name: "model_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{},
})
reply, ok := a.redirectModelCreateSessionToStrategyCreateIfNeeded("default", userID, "zh", "1.AI交易策略", a.getSkillSession(userID))
if !ok {
t.Fatalf("expected strategy type choice to redirect away from model create")
}
if strings.Contains(reply, "模型提供商") || strings.Contains(reply, "provider") {
t.Fatalf("strategy type choice must not ask for model provider, got: %s", reply)
}
session := a.getSkillSession(userID)
if session.Name != "strategy_management" || session.Action != "create" {
t.Fatalf("expected active session to be strategy create, got %+v", session)
}
if got := fieldValue(session, "strategy_type"); got != "ai_trading" {
t.Fatalf("expected ai strategy type to be captured, got %q in %+v", got, session)
}
}
func TestStrategyCreateAskUserReplyIsNotOverriddenByTemplateMissingFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-llm-ask-reply.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"我会按 AI 策略模板继续填。你如果想稳健,我建议先用 OI Low、15m 主周期、最低置信度 70。确认这个方向吗"}`})
session := ActiveSkillSession{
UserID: 42,
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "AI高频",
"strategy_type": "ai_trading",
},
}
reply, handled, err := a.driveActiveSession(context.Background(), "default", 42, "zh", "1h", session, nil)
if err != nil {
t.Fatalf("drive active session: %v", err)
}
if !handled {
t.Fatalf("expected active session to be handled")
}
if strings.Contains(reply, "这份策略模板还没填完整") || strings.Contains(reply, "还缺这些字段") {
t.Fatalf("LLM ask_user reply should not be overridden by hard template missing list, got: %s", reply)
}
if !strings.Contains(reply, "OI Low") || !strings.Contains(reply, "70") {
t.Fatalf("expected LLM reply to pass through, got: %s", reply)
}
}
func TestStrategyCreateAIReplyRejectsNonTemplateInvestmentQuestion(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-ai-non-template-question.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"你打算投入多少资金来运行这个策略比如100U、500U这样我可以帮你设置止损和仓位。"}`})
session := ActiveSkillSession{
UserID: 42,
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "AI500稳健",
"strategy_type": "ai_trading",
},
}
reply, handled, err := a.driveActiveSession(context.Background(), "default", 42, "zh", "全部你定吧,稳健就行", session, nil)
if err != nil {
t.Fatalf("drive active session: %v", err)
}
if !handled {
t.Fatalf("expected active session to be handled")
}
for _, blocked := range []string{"投入多少", "100U", "500U", "止损", "仓位"} {
if strings.Contains(reply, blocked) {
t.Fatalf("AI strategy reply should not ask non-template field %q, got: %s", blocked, reply)
}
}
}
func TestStrategyCreateOptionsQuestionExplainsCurrentMissingField(t *testing.T) {
session := ActiveSkillSession{
UserID: 42,
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "AI500高频交易",
"strategy_type": "ai_trading",
},
}
reply, blocked := strategyCreateTemplateMissingReply("zh", "有哪些选择吗", session)
if !blocked {
t.Fatalf("expected options question to be handled")
}
for _, want := range []string{"AI500", "OI Top", "OI Low", "静态币种"} {
if !strings.Contains(reply, want) {
t.Fatalf("expected source options to include %q, got: %s", want, reply)
}
}
if strings.Contains(reply, "还缺") || strings.Contains(reply, "BTC/ETH 最大杠杆") {
t.Fatalf("options question should not repeat the full missing-field list, got: %s", reply)
}
}
func TestStrategyCreateMissingFieldsIncludeInlineOptions(t *testing.T) {
reply := formatStrategyCreateConfigNeeded("zh", "source_type,primary_timeframe,btceth_max_leverage,min_confidence,trading_frequency")
for _, want := range []string{"AI500", "OI Top", "OI Low", "静态币种", "1m", "1h", "120", "50100", "每天最多"} {
if !strings.Contains(reply, want) {
t.Fatalf("expected missing-field prompt to include option/range %q, got: %s", want, reply)
}
}
if !strings.Contains(reply, "你帮我按稳健/高频/激进来推荐") {
t.Fatalf("expected prompt to offer recommendation shortcut, got: %s", reply)
}
}
func TestStrategyCreateConfigPatchReplyUsesStructuredMissingFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-recommendation-structured-missing.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"我建议按高频但稳健来填:主周期 3m多周期 3m/5m/15mBTC/ETH 3倍山寨币 2倍。确认的话我会继续整理完整模板。","extracted_data":{"config_patch":{"strategy_type":"ai_trading","ai_config":{"indicators":{"klines":{"primary_timeframe":"3m","selected_timeframes":["3m","5m","15m"]}}}}}}`})
session := ActiveSkillSession{
UserID: 42,
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "AI500高频交易",
"strategy_type": "ai_trading",
strategyCreateConfigPatchField: map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{"source_type": "ai500", "use_ai500": true, "ai500_limit": 5},
"risk_control": map[string]any{
"min_confidence": 70,
},
},
},
},
}
reply, handled, err := a.driveActiveSession(context.Background(), "default", 42, "zh", "继续", session, nil)
if err != nil {
t.Fatalf("drive active session: %v", err)
}
if !handled {
t.Fatalf("expected recommendation request to be handled")
}
if !strings.Contains(reply, "这份策略模板还没填完整") {
t.Fatalf("expected structured missing-field prompt after partial config_patch, got: %s", reply)
}
if strings.Contains(reply, "我建议按高频但稳健来填") {
t.Fatalf("LLM free-form recommendation should not be used as the current plan, got: %s", reply)
}
if !strings.Contains(reply, "BTC/ETH 最大杠杆") || !strings.Contains(reply, "开仓标准") {
t.Fatalf("expected deterministic missing template fields, got: %s", reply)
}
}
func TestStrategyCreateFirstStageConfigProgressUsesStructuredMissingFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-first-stage-structured-missing.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
a.SetAIClient(&staticAIClient{response: `{"route":"ask_user","reply":"收到AI500 和最低置信度 80 是你指定的;其他我建议按高频稳健来定:主周期 3m多周期 3m/5m/15mBTC/ETH 3倍山寨币 2倍最小盈亏比 2。","extracted_data":{}}`})
session := ActiveSkillSession{
UserID: 42,
SkillName: "strategy_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "高频稳健AI500",
"strategy_type": "ai_trading",
strategyCreateConfigProgressThisTurnField: true,
strategyCreateConfigPatchField: map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{"source_type": "ai500", "use_ai500": true},
"risk_control": map[string]any{
"min_confidence": 80,
},
},
},
},
}
reply, handled, err := a.driveActiveSession(context.Background(), "default", 42, "zh", "选币选AI500最新置信度80其他你定能高频交易稳定就行", session, nil)
if err != nil {
t.Fatalf("drive active session: %v", err)
}
if !handled {
t.Fatalf("expected active session to be handled")
}
if !strings.Contains(reply, "这份策略模板还没填完整") {
t.Fatalf("expected structured missing-field prompt after first-stage config progress, got: %s", reply)
}
if strings.Contains(reply, "其他我建议按高频稳健来定") {
t.Fatalf("LLM free-form recommendation should not be used as the current plan, got: %s", reply)
}
if !strings.Contains(reply, "主周期") || !strings.Contains(reply, "BTC/ETH 最大杠杆") {
t.Fatalf("expected deterministic missing template fields, got: %s", reply)
}
}
func TestStrategyCreateConfirmationUsesModelRepairForPriorStyleProposal(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-style-repair.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
a.SetAIClient(&staticAIClient{response: `{"route":"execute_skill","extracted_data":{"config_patch":{"strategy_type":"ai_trading","ai_config":{"coin_source":{"source_type":"ai500","use_ai500":true,"ai500_limit":3},"indicators":{"klines":{"primary_timeframe":"1m","primary_count":20,"selected_timeframes":["1m","5m","15m"],"enable_multi_timeframe":true,"enable_raw_klines":true},"enable_volume":true,"enable_oi":true,"enable_funding_rate":true,"enable_quant_data":true},"risk_control":{"btc_eth_max_leverage":5,"altcoin_max_leverage":5,"min_confidence":75,"min_risk_reward_ratio":3},"prompt_sections":{"trading_frequency":"高频但不过度交易:目标每小时 1-3 笔;单笔持仓通常 10-30 分钟。","entry_standards":"只在短周期趋势、成交量/OI、资金费率或排行信号形成共振时入场。"}}}}}`})
userID := int64(42)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.CollectedFields = map[string]any{
"name": "AI500极致稳定高频",
"strategy_type": "ai_trading",
}
session.LocalHistory = []chatMessage{
{Role: "assistant", Content: "我建议主周期改成1分钟多周期改成1分钟、5分钟、15分钟交易频率按高频但稳定来写。"},
}
reply, handled, err := a.driveActiveSession(context.Background(), "default", userID, "zh", "好的可以,确认创建", session, nil)
if err != nil {
t.Fatalf("drive active session: %v", err)
}
if !handled {
t.Fatalf("expected confirmation to be handled")
}
if !strings.Contains(reply, "已创建策略") {
t.Fatalf("expected real strategy creation after model repair, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
if len(strategies) != 1 {
t.Fatalf("expected one created strategy, got %d", len(strategies))
}
var cfg store.StrategyConfig
if err := json.Unmarshal([]byte(strategies[0].Config), &cfg); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
if cfg.CoinSource.SourceType != "ai500" || cfg.Indicators.Klines.PrimaryTimeframe != "1m" {
t.Fatalf("expected model-repaired AI500 1m strategy, got %+v", cfg)
}
}
func TestStrategyCreateCreatesGridAfterConfigPatch(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-ready.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
patch := map[string]any{
"strategy_type": "grid_trading",
"grid_config": map[string]any{
"symbol": "ETHUSDT",
"grid_count": 12,
"total_investment": 1000,
"leverage": 3,
"use_atr_bounds": true,
"atr_multiplier": 2,
"distribution": "gaussian",
"max_drawdown_pct": 15,
"stop_loss_pct": 5,
"daily_loss_limit_pct": 10,
"use_maker_only": true,
},
}
rawPatch, _ := json.Marshal(patch)
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "我的网格策略",
strategyCreateConfigPatchField: string(rawPatch),
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session)
if !strings.Contains(reply, "已创建策略") {
t.Fatalf("expected create reply, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
var created *store.Strategy
for _, strategy := range strategies {
if strategy.Name == "我的网格策略" {
created = strategy
break
}
}
if created == nil {
t.Fatalf("expected grid strategy to be created")
}
var cfg store.StrategyConfig
if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
if cfg.StrategyType != "grid_trading" || cfg.GridConfig == nil || cfg.GridConfig.Symbol != "ETHUSDT" {
t.Fatalf("expected grid config to persist, got %+v", cfg)
}
}
func TestManageStrategyToolCreateRequiresConfirmation(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-tool-create-confirmation.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageStrategy("default", `{"action":"create","name":"未确认网格","lang":"zh","config":{"strategy_type":"grid_trading","grid_config":{"symbol":"BTCUSDT","total_investment":200,"use_atr_bounds":true}}}`)
if !strings.Contains(resp, "requires_confirmation") {
t.Fatalf("expected tool create to require confirmation, got: %s", resp)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
for _, strategy := range strategies {
if strategy.Name == "未确认网格" {
t.Fatalf("unconfirmed tool call should not create strategy")
}
}
resp = a.toolManageStrategy("default", `{"action":"create","name":"已确认网格","lang":"zh","confirmed":true,"allow_clamped_update":true,"config":{"strategy_type":"grid_trading","grid_config":{"symbol":"BTCUSDT","total_investment":200,"use_atr_bounds":true}}}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected confirmed create to succeed, got: %s", resp)
}
}
func TestStrategyCreateGridPatchInfersStrategyType(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-infers-type.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
patch := map[string]any{
"grid_config": map[string]any{
"symbol": "BTCUSDT",
"grid_count": 20,
"total_investment": 200,
"leverage": 2,
"use_atr_bounds": true,
"atr_multiplier": 2,
"distribution": "uniform",
"max_drawdown_pct": 15,
"stop_loss_pct": 5,
"daily_loss_limit_pct": 10,
"use_maker_only": true,
},
}
rawPatch, _ := json.Marshal(patch)
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "小白网格",
strategyCreateConfigPatchField: string(rawPatch),
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session)
if !strings.Contains(reply, "已创建策略") {
t.Fatalf("expected create reply, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
var cfg store.StrategyConfig
for _, strategy := range strategies {
if strategy.Name == "小白网格" {
if err := json.Unmarshal([]byte(strategy.Config), &cfg); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
break
}
}
if cfg.StrategyType != "grid_trading" || cfg.GridConfig == nil || cfg.GridConfig.Symbol != "BTCUSDT" {
t.Fatalf("expected grid patch to infer grid_trading, got %+v", cfg)
}
}
func TestStrategyCreateGridPatchKeepsBackendGridDefaults(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-defaults.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
patch := map[string]any{
"strategy_type": "grid_trading",
"grid_config": map[string]any{
"symbol": "ETHUSDT",
"grid_count": 20,
"total_investment": 500,
"leverage": 3,
},
}
rawPatch, _ := json.Marshal(patch)
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "餐巾纸",
strategyCreateConfigPatchField: string(rawPatch),
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session)
if !strings.Contains(reply, "还缺") || strings.Contains(reply, "已创建策略") {
t.Fatalf("expected incomplete grid patch to ask for missing fields, got: %s", reply)
}
}
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)
}
}