Files
nofx/agent/config_tools_test.go
T
shinchan-zhai 4cadf6f442 fix(agent,claw402): harden agent runtime and strip max_tokens for thinking models
- Fix Stop() race condition using sync.Once
- Add ensureHistory() to prevent nil panic in planner/dispatcher
- Add bounds check on trader ID slicing
- Log saveExecutionState and clearSetupState errors instead of discarding
- Remove always-true modelID condition in onboard setup
- Add Chinese setup keywords and expand model name aliases
- Strip max_tokens from claw402 requests to avoid thinking-model budget exhaustion
- Hide Agent nav tab (Beta) pending merge to main
- Sync tests with code changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 11:48:37 +08:00

388 lines
12 KiB
Go

package agent
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func newTestAgentWithStore(t *testing.T) *Agent {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("create test store: %v", err)
}
t.Cleanup(func() {
_ = st.Close()
})
return &Agent{store: st}
}
func TestToolManageExchangeConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true,
"testnet":true
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Exchange.AccountName != "Main" || created.Exchange.ExchangeType != "binance" {
t.Fatalf("unexpected exchange payload: %+v", created.Exchange)
}
updateResp := a.toolManageExchangeConfig("user-1", `{
"action":"update",
"exchange_id":"`+created.Exchange.ID+`",
"account_name":"Renamed",
"enabled":false
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Exchange.AccountName != "Renamed" || updated.Exchange.Enabled {
t.Fatalf("unexpected updated exchange payload: %+v", updated.Exchange)
}
deleteResp := a.toolManageExchangeConfig("user-1", `{
"action":"delete",
"exchange_id":"`+created.Exchange.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Model.Provider != "openai" || created.Model.CustomModelName != "gpt-5-mini" {
t.Fatalf("unexpected model payload: %+v", created.Model)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":false,
"custom_model_name":"gpt-5"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Model.Enabled || updated.Model.CustomModelName != "gpt-5" {
t.Fatalf("unexpected updated model payload: %+v", updated.Model)
}
deleteResp := a.toolManageModelConfig("user-1", `{
"action":"delete",
"model_id":"`+created.Model.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigRejectsEnableWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":false,
"custom_model_name":"gpt-4o"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":true
}`)
if !strings.Contains(updateResp, "cannot enable model config before API key is configured") {
t.Fatalf("expected enabling incomplete model to fail, got %s", updateResp)
}
}
func TestGetDefaultSkipsEnabledModelWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
incompleteCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_model_name":"gpt-4o"
}`)
var incomplete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(incompleteCreate), &incomplete); err != nil {
t.Fatalf("unmarshal incomplete create response: %v\nraw=%s", err, incompleteCreate)
}
completeCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_model_name":"deepseek-chat"
}`)
var complete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(completeCreate), &complete); err != nil {
t.Fatalf("unmarshal complete create response: %v\nraw=%s", err, completeCreate)
}
model, err := a.store.AIModel().GetDefault("user-1")
if err != nil {
t.Fatalf("GetDefault() error = %v", err)
}
if model.ID != complete.Model.ID {
t.Fatalf("expected GetDefault to skip incomplete enabled model and return %s, got %s", complete.Model.ID, model.ID)
}
}
func TestToolManageTraderLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create trader response: %+v", created)
}
if created.Trader.Name != "Momentum Trader" || created.Trader.ScanIntervalMinutes != 5 {
t.Fatalf("unexpected created trader: %+v", created.Trader)
}
listResp := a.toolManageTrader("user-1", `{"action":"list"}`)
var listed struct {
Count int `json:"count"`
Traders []safeTraderToolConfig `json:"traders"`
}
if err := json.Unmarshal([]byte(listResp), &listed); err != nil {
t.Fatalf("unmarshal list response: %v\nraw=%s", err, listResp)
}
if listed.Count != 1 || len(listed.Traders) != 1 {
t.Fatalf("unexpected trader list: %+v", listed)
}
updateResp := a.toolManageTrader("user-1", `{
"action":"update",
"trader_id":"`+created.Trader.ID+`",
"name":"Renamed Trader",
"scan_interval_minutes":8
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update trader response: %v\nraw=%s", err, updateResp)
}
if updated.Trader.Name != "Renamed Trader" || updated.Trader.ScanIntervalMinutes != 8 {
t.Fatalf("unexpected updated trader: %+v", updated.Trader)
}
deleteResp := a.toolManageTrader("user-1", `{
"action":"delete",
"trader_id":"`+created.Trader.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete trader response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete trader response: %+v", deleted)
}
}
func TestToolManageStrategyLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy payload: %+v", created.Strategy)
}
listResp := a.toolGetStrategies("user-1")
if !strings.Contains(listResp, "激进") {
t.Fatalf("expected created strategy in list, got %s", listResp)
}
updateResp := a.toolManageStrategy("user-1", `{
"action":"update",
"strategy_id":"`+created.Strategy.ID+`",
"description":"更新后的描述"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Strategy.Description != "更新后的描述" {
t.Fatalf("unexpected updated strategy payload: %+v", updated.Strategy)
}
activateResp := a.toolManageStrategy("user-1", `{
"action":"activate",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(activateResp, `"action":"activate"`) {
t.Fatalf("unexpected activate response: %s", activateResp)
}
deleteResp := a.toolManageStrategy("user-1", `{
"action":"delete",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(deleteResp, `"action":"delete"`) {
t.Fatalf("unexpected delete response: %s", deleteResp)
}
}
func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) {
a := newTestAgentWithStore(t)
if err := a.store.AIModel().Update("user-42", "openai", true, "sk-test", "https://api.openai.com/v1", "gpt-5-mini"); err != nil {
t.Fatalf("seed model: %v", err)
}
client, modelName, ok := a.loadAIClientFromStoreUser("user-42")
if !ok {
t.Fatal("expected AI client to load from user-specific model")
}
if client == nil {
t.Fatal("expected non-nil AI client")
}
if modelName != "gpt-5-mini" {
t.Fatalf("unexpected model name: %s", modelName)
}
// After the provider registry refactor, registered providers (like openai)
// return their own AIClient implementation, not *mcp.Client.
if client == nil {
t.Fatal("expected non-nil AI client from provider registry")
}
}