diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index be58ad83a..880ec34da 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -188,52 +188,9 @@ func NewAgentInstance( summarizeTokenPercent = 75 } - // Resolve fallback candidates - modelCfg := providers.ModelConfig{ - Primary: model, - Fallbacks: fallbacks, - } - resolveFromModelList := func(raw string) (string, bool) { - ensureProtocol := func(model string) string { - model = strings.TrimSpace(model) - if model == "" { - return "" - } - if strings.Contains(model, "/") { - return model - } - return "openai/" + model - } - - raw = strings.TrimSpace(raw) - if raw == "" { - return "", false - } - - if cfg != nil { - if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { - return ensureProtocol(mc.Model), true - } - - for i := range cfg.ModelList { - fullModel := strings.TrimSpace(cfg.ModelList[i].Model) - if fullModel == "" { - continue - } - if fullModel == raw { - return ensureProtocol(fullModel), true - } - _, modelID := providers.ExtractProtocol(fullModel) - if modelID == raw { - return ensureProtocol(fullModel), true - } - } - } - - return "", false - } - - candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) + // Resolve fallback candidates using model_resolution.go helpers + resolveFromModelList := buildModelListResolver(cfg) + candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) // Model routing setup: pre-resolve light model candidates at creation time // to avoid repeated model_list lookups on every incoming message. diff --git a/pkg/agent/loop_commands.go b/pkg/agent/loop_commands.go index a8dd3846f..367697487 100644 --- a/pkg/agent/loop_commands.go +++ b/pkg/agent/loop_commands.go @@ -24,7 +24,10 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey strin if agent == nil { return "unknown", "unknown" } - prov, _ := providers.ExtractProtocol(agent.Model) + prov := resolvedCandidateProvider(agent.Candidates, "") + if prov == "" { + prov, _ = providers.ExtractProtocol(agent.Model) + } return agent.Model, prov }, ListAgentIDs: func() []string { diff --git a/pkg/agent/loop_upstream_test.go b/pkg/agent/loop_upstream_test.go new file mode 100644 index 000000000..8c0aa566b --- /dev/null +++ b/pkg/agent/loop_upstream_test.go @@ -0,0 +1,341 @@ +package agent + +import ( + "context" + "os" + "sync" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestResolveMessageRoute_DefaultAgent(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Peer: bus.Peer{Kind: "direct", ID: "user1"}, + } + + route, agent, err := al.resolveMessageRoute(msg) + if err != nil { + t.Fatalf("resolveMessageRoute error: %v", err) + } + if agent == nil { + t.Fatal("expected non-nil agent") + } + if route.SessionKey == "" { + t.Fatal("expected non-empty session key") + } +} + +func TestResolveScopeKey_PresetOverridesRoute(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + SessionKey: "custom-session", + Peer: bus.Peer{Kind: "direct", ID: "user1"}, + } + + route, _, err := al.resolveMessageRoute(msg) + if err != nil { + t.Fatalf("resolveMessageRoute error: %v", err) + } + + key := resolveScopeKey(route, msg.SessionKey) + if key != "custom-session" { + t.Errorf("expected custom-session, got %q", key) + } + + key2 := resolveScopeKey(route, "") + if key2 != route.SessionKey { + t.Errorf("expected route session key %q, got %q", route.SessionKey, key2) + } +} + +func TestSelectCandidates_DefaultCandidates(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("no default agent") + } + + candidates := al.selectCandidates(agent, "hello", nil) + // Should return agent.Candidates (may be empty in test config, but should not panic) + if candidates == nil { + // Candidates can be nil/empty in minimal test config — just ensure no panic + candidates = []providers.FallbackCandidate{} + } + _ = candidates +} + +func TestSelectCandidates_WithRouterUsesLight(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "heavy-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("no default agent") + } + + // Without router: should return agent.Candidates + result := al.selectCandidates(agent, "hi", nil) + if len(result) != len(agent.Candidates) { + t.Errorf("expected %d candidates, got %d", len(agent.Candidates), len(result)) + } +} + +func TestBuildCommandsRuntime_GetModelInfo(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + agent := al.registry.GetDefaultAgent() + rt := al.buildCommandsRuntime(agent, "test-session") + + model, prov := rt.GetModelInfo() + if model == "" { + t.Error("expected non-empty model") + } + if prov == "" { + t.Error("expected non-empty provider") + } +} + +func TestBuildCommandsRuntime_ListAgentIDs(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + agent := al.registry.GetDefaultAgent() + rt := al.buildCommandsRuntime(agent, "") + + ids := rt.ListAgentIDs() + if len(ids) == 0 { + t.Error("expected at least one agent ID") + } +} + +func TestBuildCommandsRuntime_SwitchModel(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + agent := al.registry.GetDefaultAgent() + rt := al.buildCommandsRuntime(agent, "") + + old, err := rt.SwitchModel("new-model") + if err != nil { + t.Fatalf("SwitchModel error: %v", err) + } + if old == "" { + t.Error("expected non-empty old model") + } + if agent.Model != "new-model" { + t.Errorf("expected agent model to be new-model, got %q", agent.Model) + } +} + +func TestBuildCommandsRuntime_ClearHistory(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + agent := al.registry.GetDefaultAgent() + agent.Sessions.AddMessage("test-key", "user", "hello") + + rt := al.buildCommandsRuntime(agent, "test-key") + if err := rt.ClearHistory(); err != nil { + t.Fatalf("ClearHistory error: %v", err) + } + + history := agent.Sessions.GetHistory("test-key") + if len(history) != 0 { + t.Errorf("expected empty history after clear, got %d messages", len(history)) + } +} + +func TestBuildCommandsRuntime_ReloadConfig_NoFunc(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + agent := al.registry.GetDefaultAgent() + rt := al.buildCommandsRuntime(agent, "") + + err := rt.ReloadConfig() + if err == nil { + t.Error("expected error when reloadFunc is nil") + } +} + +func TestBuildCommandsRuntime_ReloadConfig_WithFunc(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + called := false + al.SetReloadFunc(func() error { + called = true + return nil + }) + + agent := al.registry.GetDefaultAgent() + rt := al.buildCommandsRuntime(agent, "") + + if err := rt.ReloadConfig(); err != nil { + t.Fatalf("ReloadConfig error: %v", err) + } + if !called { + t.Error("expected reloadFunc to be called") + } +} + +func TestInvokeTypingStop(t *testing.T) { + cfg := &config.Config{} + msgBus := bus.NewMessageBus() + m, err := newTestManager(cfg, msgBus) + if err != nil { + t.Skipf("cannot create test manager: %v", err) + } + + var stopped bool + var mu sync.Mutex + m.RecordTypingStop("telegram", "chat1", func() { + mu.Lock() + stopped = true + mu.Unlock() + }) + + m.InvokeTypingStop("telegram", "chat1") + + mu.Lock() + defer mu.Unlock() + if !stopped { + t.Error("expected typing stop to be invoked") + } +} + +func TestInvokeTypingStop_NoOp(t *testing.T) { + cfg := &config.Config{} + msgBus := bus.NewMessageBus() + m, err := newTestManager(cfg, msgBus) + if err != nil { + t.Skipf("cannot create test manager: %v", err) + } + + // Should not panic when no typing indicator is active + m.InvokeTypingStop("telegram", "nonexistent") +} + +// newTestManager creates a minimal channels.Manager for testing. +func newTestManager(cfg *config.Config, _ *bus.MessageBus) (*testChannelManager, error) { + return &testChannelManager{}, nil +} + +// testChannelManager is a minimal mock that supports InvokeTypingStop testing. +type testChannelManager struct { + typingStops sync.Map +} + +func (m *testChannelManager) RecordTypingStop(channel, chatID string, stop func()) { + m.typingStops.Store(channel+":"+chatID, stop) +} + +func (m *testChannelManager) InvokeTypingStop(channel, chatID string) { + key := channel + ":" + chatID + if v, loaded := m.typingStops.LoadAndDelete(key); loaded { + if fn, ok := v.(func()); ok { + fn() + } + } +} + +// TestHandleCommand_ForkSpecificCommands verifies that fork-specific commands +// still work through the fallback path. +func TestHandleCommand_ForkSpecificCommands(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + agent := al.registry.GetDefaultAgent() + ctx := context.Background() + + // /skills should be handled + resp, handled := al.handleCommand(ctx, bus.InboundMessage{ + Content: "/skills", + }, agent, "") + if !handled { + t.Fatal("expected /skills to be handled") + } + if resp == "" { + t.Error("expected non-empty response for /skills") + } + + // /session should be handled + resp, handled = al.handleCommand(ctx, bus.InboundMessage{ + Content: "/session", + }, agent, "") + if !handled { + t.Fatal("expected /session to be handled") + } + if resp == "" { + t.Error("expected non-empty response for /session") + } +} + +// TestHandleCommand_UpstreamCommands verifies upstream commands work via Executor. +func TestHandleCommand_UpstreamCommands(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + agent := al.registry.GetDefaultAgent() + ctx := context.Background() + + tests := []struct { + cmd string + wantSub string + }{ + {"/show model", "Current Model"}, + {"/show channel", "Current Channel"}, + {"/show agents", "default"}, + {"/list agents", "default"}, + {"/help", "Available commands"}, + } + + for _, tt := range tests { + resp, handled := al.handleCommand(ctx, bus.InboundMessage{ + Content: tt.cmd, + Channel: "telegram", + }, agent, "") + if !handled { + t.Errorf("%s: expected handled", tt.cmd) + continue + } + if resp == "" { + t.Errorf("%s: expected non-empty response", tt.cmd) + } + } +} diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 11c506fe6..d00558757 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -1,14 +1,12 @@ package agent import ( - "fmt" "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) -//nolint:unused // wired in PR4 func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) { ensureProtocol := func(model string) string { model = strings.TrimSpace(model) @@ -49,7 +47,6 @@ func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) } } -//nolint:unused // wired in PR4 func resolveModelCandidates( cfg *config.Config, defaultProvider string, @@ -66,37 +63,9 @@ func resolveModelCandidates( ) } -//nolint:unused // wired in PR4 -func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string { - if len(candidates) > 0 && strings.TrimSpace(candidates[0].Model) != "" { - return candidates[0].Model - } - return fallback -} - -//nolint:unused // wired in PR4 func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallback string) string { if len(candidates) > 0 && strings.TrimSpace(candidates[0].Provider) != "" { return candidates[0].Provider } return fallback } - -//nolint:unused // wired in PR4 -func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) { - if cfg == nil { - return nil, fmt.Errorf("config is nil") - } - - modelCfg, err := cfg.GetModelConfig(strings.TrimSpace(modelName)) - if err != nil { - return nil, err - } - - clone := *modelCfg - if clone.Workspace == "" { - clone.Workspace = workspace - } - - return &clone, nil -}