Files
goclaw/internal/mcp/manager_lazy_test.go
T
Kai (Tam Nhu) Tran 30708ae79d feat(providers): support Codex OAuth pools with inherited routing defaults
* feat(auth): support named chatgpt oauth providers

- add provider-scoped ChatGPT OAuth routes and CLI support

- persist refresh tokens per provider and reject provider-type collisions

- wire provider OAuth setup flows in the dashboard and setup UI

Refs #448

* feat(agent): add chatgpt oauth account routing

- add agent other_config routing for manual and round-robin selection

- reuse routed provider resolution across resolver and pending loaders

- add router, parser, and agent advanced dialog coverage for multi-account use

Refs #448

* docs(api): describe chatgpt oauth routing

- document named-provider ChatGPT OAuth auth routes

- describe agent-side account routing and round-robin behavior

- update OpenAPI agent config schema and provider type enum

Refs #448

* fix(store): add missing agent key context helpers

* feat(ui): clarify chatgpt oauth account setup and routing

* docs(providers): align chatgpt oauth alias examples

* feat(agent): add codex pool activity dashboard

* fix(providers): harden codex oauth alias setup

* feat(codex-pool): improve routing dashboard UX

- redesign the Codex/OpenAI pool page around saved-pool checkpoints and live evidence

- add clearer selection, attention, and recent-proof states for pool members

- make the lower panels fill the remaining desktop viewport while staying responsive

* fix(store): resolve context helper merge duplication

* feat(oauth): add codex pool quota and observation APIs

- add quota inspection and observation endpoints for ChatGPT Subscription (OAuth) providers

- teach codex routing to surface pool activity, observation metadata, and quota-aware readiness

- extend tests and HTTP docs/OpenAPI for the new pool monitoring flows

* feat(web): add codex pool quota monitor and controls

- add provider quota fetching, readiness badges, and live routing evidence on the account pool page

- redesign pool setup and activity panels for multi-account management with localized copy updates

- keep the live monitor internally scrollable and compact the account cards for better viewport fit

* fix(web): clarify pool routing labels

- rename the recent request badge from Direct to Selected

- restore compact quota bars in the live pool cards

* feat(codex-pool): add runtime health dashboard

- derive per-provider success and failure health from routed Codex traces

- surface routing, quota, and recent request evidence in the pool UI

- align provider alias guidance and owner access with the dashboard role model

* docs(auth): document tenant scoping and key roles

* fix(auth): harden tenant and codex pool access control

* fix(providers): align codex pool runtime defaults

* feat(ui): tighten codex pool responsive layout

* feat(chatgpt-oauth): refine codex pool management UX

* feat(chatgpt-oauth): surface quota bars on provider pages

- add compact quota bars to Codex provider rows and provider detail

- fetch quota only for ready visible provider rows and ready detail aliases

- fix managed-member detail visibility and tighten provider locale copy
2026-03-27 09:35:57 +07:00

282 lines
8.7 KiB
Go

package mcp
import (
"slices"
"sync"
"testing"
mcpgo "github.com/mark3labs/mcp-go/mcp"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// makeBridgeTool creates a minimal BridgeTool for testing without a real MCP connection.
func makeBridgeTool(serverName, toolName string) *BridgeTool {
return NewBridgeTool(serverName, mcpgo.Tool{
Name: toolName,
Description: "test tool " + toolName,
InputSchema: mcpgo.ToolInputSchema{Type: "object"},
}, nil, "", 30, nil)
}
// setupSearchModeManager returns a Manager already in search mode with the given
// original tool names deferred. Bypasses real MCP connections by populating
// deferredTools directly.
func setupSearchModeManager(t *testing.T, serverName string, originalNames []string) (*Manager, *tools.Registry) {
t.Helper()
reg := tools.NewRegistry()
m := NewManager(reg)
m.mu.Lock()
m.deferredTools = make(map[string]*BridgeTool, len(originalNames))
m.activatedTools = make(map[string]struct{})
m.searchMode = true
for _, orig := range originalNames {
bt := makeBridgeTool(serverName, orig)
m.deferredTools[bt.Name()] = bt
}
m.mu.Unlock()
return m, reg
}
// deferredCount returns the current number of deferred tools (read-locked).
func deferredCount(m *Manager) int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.deferredTools)
}
// --- ActivateToolIfDeferred ---
func TestManager_ActivateToolIfDeferred_NotInDeferred(t *testing.T) {
m, _ := setupSearchModeManager(t, "svc", []string{"get_data", "list_items"})
if m.ActivateToolIfDeferred("mcp_svc__nonexistent") {
t.Error("expected false for tool not in deferredTools")
}
}
func TestManager_ActivateToolIfDeferred_Success(t *testing.T) {
m, reg := setupSearchModeManager(t, "svc", []string{"get_data"})
name := makeBridgeTool("svc", "get_data").Name()
if !m.ActivateToolIfDeferred(name) {
t.Fatalf("expected true: tool %q should be activatable", name)
}
// Tool must now be in the registry.
if _, ok := reg.Get(name); !ok {
t.Errorf("tool %q should be in registry after activation", name)
}
// Tool must be removed from deferredTools.
m.mu.RLock()
_, stillDeferred := m.deferredTools[name]
m.mu.RUnlock()
if stillDeferred {
t.Error("tool should be removed from deferredTools after activation")
}
// Tool must appear in activatedTools.
m.mu.RLock()
_, activated := m.activatedTools[name]
m.mu.RUnlock()
if !activated {
t.Error("tool should be tracked in activatedTools")
}
}
func TestManager_ActivateToolIfDeferred_Idempotent(t *testing.T) {
m, reg := setupSearchModeManager(t, "svc", []string{"get_data"})
name := makeBridgeTool("svc", "get_data").Name()
// First activation succeeds.
if !m.ActivateToolIfDeferred(name) {
t.Fatal("first activation should return true")
}
// Second call: tool is no longer in deferredTools but is in activatedTools.
// Must return true (idempotent) so concurrent goroutines don't get blocked.
second := m.ActivateToolIfDeferred(name)
if !second {
t.Error("second call should return true — tool is in activatedTools")
}
if _, ok := reg.Get(name); !ok {
t.Error("tool should still be in registry after second call")
}
}
func TestManager_ActivateToolIfDeferred_NotInSearchMode(t *testing.T) {
reg := tools.NewRegistry()
m := NewManager(reg)
// searchMode = false, deferredTools = nil.
if m.ActivateToolIfDeferred("any_tool") {
t.Error("expected false when not in search mode")
}
}
func TestManager_ActivateToolIfDeferred_IndependentPerTool(t *testing.T) {
// Activating one tool must not affect others still in deferredTools.
origNames := []string{"get_activities_by_day", "get_daily_stats", "list_activities", "get_baby", "record_growth"}
m, reg := setupSearchModeManager(t, "tinytimeline", origNames)
btA := makeBridgeTool("tinytimeline", "get_activities_by_day")
btB := makeBridgeTool("tinytimeline", "get_daily_stats")
if !m.ActivateToolIfDeferred(btA.Name()) {
t.Fatalf("activation of %q failed", btA.Name())
}
if !m.ActivateToolIfDeferred(btB.Name()) {
t.Fatalf("activation of %q failed", btB.Name())
}
// Two activated, three still deferred.
if got := deferredCount(m); got != 3 {
t.Errorf("expected 3 tools still deferred, got %d", got)
}
// Both activated tools are in the registry.
for _, bt := range []*BridgeTool{btA, btB} {
if _, ok := reg.Get(bt.Name()); !ok {
t.Errorf("tool %q should be in registry", bt.Name())
}
}
}
func TestManager_ActivateToolIfDeferred_Concurrent(t *testing.T) {
// Concurrently activate different tools — no data races, all end up in registry.
origNames := []string{"tool_a", "tool_b", "tool_c", "tool_d", "tool_e"}
m, reg := setupSearchModeManager(t, "svc", origNames)
var wg sync.WaitGroup
for _, orig := range origNames {
name := makeBridgeTool("svc", orig).Name()
wg.Add(1)
go func(n string) {
defer wg.Done()
m.ActivateToolIfDeferred(n)
}(name)
}
wg.Wait()
for _, orig := range origNames {
name := makeBridgeTool("svc", orig).Name()
if _, ok := reg.Get(name); !ok {
t.Errorf("tool %q should be in registry after concurrent activation", name)
}
}
}
func TestManager_ActivateToolIfDeferred_SameToolRace(t *testing.T) {
// Many goroutines racing to activate the same tool — all must return true.
m, reg := setupSearchModeManager(t, "svc", []string{"shared_tool"})
name := makeBridgeTool("svc", "shared_tool").Name()
const goroutines = 20
results := make([]bool, goroutines)
var wg sync.WaitGroup
for i := range goroutines {
wg.Add(1)
go func(idx int) {
defer wg.Done()
results[idx] = m.ActivateToolIfDeferred(name)
}(i)
}
wg.Wait()
if _, ok := reg.Get(name); !ok {
t.Error("tool should be in registry after concurrent activation")
}
for i, ok := range results {
if !ok {
t.Errorf("goroutine %d got false — all should return true (idempotent)", i)
}
}
}
// --- Registry.SetDeferredActivator wired to Manager.ActivateToolIfDeferred ---
func TestRegistry_WiredToManager_LazyActivation(t *testing.T) {
// End-to-end: mirrors what resolver.go does when search mode is detected.
// Verifies that TryActivateDeferred activates a deferred MCP tool correctly.
m, reg := setupSearchModeManager(t, "tinytimeline", []string{"get_activities_by_day", "get_daily_stats"})
reg.SetDeferredActivator(m.ActivateToolIfDeferred)
name := makeBridgeTool("tinytimeline", "get_activities_by_day").Name()
// Tool is NOT in registry before lazy activation (it is deferred).
if _, ok := reg.Get(name); ok {
t.Fatal("tool should not be in registry before lazy activation")
}
// Simulate what loop.go does: TryActivateDeferred when allowedTools blocks the call.
if !reg.TryActivateDeferred(name) {
t.Fatalf("TryActivateDeferred should succeed for deferred tool %q", name)
}
// Tool must now be in the registry.
if _, ok := reg.Get(name); !ok {
t.Errorf("tool %q should be in registry after TryActivateDeferred", name)
}
}
func TestRegistry_WiredToManager_UnknownToolReturnsFalse(t *testing.T) {
m, reg := setupSearchModeManager(t, "svc", []string{"tool_a"})
reg.SetDeferredActivator(m.ActivateToolIfDeferred)
if reg.TryActivateDeferred("mcp_svc__nonexistent") {
t.Error("expected false for a tool that was never deferred")
}
}
func TestRegistry_WiredToManager_ActivatedToolAppearsInList(t *testing.T) {
// After TryActivateDeferred, the tool must appear in registry.List() so
// FilterTools includes it in the allowed set on the next loop iteration.
m, reg := setupSearchModeManager(t, "svc", []string{"get_data"})
reg.SetDeferredActivator(m.ActivateToolIfDeferred)
name := makeBridgeTool("svc", "get_data").Name()
// Not in List() before activation.
for _, n := range reg.List() {
if n == name {
t.Fatal("tool should not be in List() before activation")
}
}
reg.TryActivateDeferred(name)
// Appears in List() after activation.
found := slices.Contains(reg.List(), name)
if !found {
t.Errorf("tool %q should appear in registry.List() after activation", name)
}
}
func TestRegistry_WiredToManager_MultipleActivations_AllInList(t *testing.T) {
origNames := []string{"get_activities_by_day", "get_daily_stats", "list_babies"}
m, reg := setupSearchModeManager(t, "tinytimeline", origNames)
reg.SetDeferredActivator(m.ActivateToolIfDeferred)
for _, orig := range origNames {
name := makeBridgeTool("tinytimeline", orig).Name()
if !reg.TryActivateDeferred(name) {
t.Errorf("TryActivateDeferred failed for %q", name)
}
}
listed := make(map[string]bool, len(reg.List()))
for _, n := range reg.List() {
listed[n] = true
}
for _, orig := range origNames {
name := makeBridgeTool("tinytimeline", orig).Name()
if !listed[name] {
t.Errorf("tool %q missing from registry.List() after activation", name)
}
}
// deferredTools should now be empty.
if got := deferredCount(m); got != 0 {
t.Errorf("expected 0 deferred tools after all activations, got %d", got)
}
}