diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index 277d1bda..9c7584a3 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -333,6 +333,15 @@ func (al *AgentLoop) buildCommandsRuntime( if opts == nil { return fmt.Errorf("process options not available") } + // /clear can arrive before any turn has persisted session scope + // metadata (runAgentLoop records it per turn), so record it here to + // let the ContextManager resolve which agent owns the session. + ensureSessionMetadata( + agent.Sessions, + opts.Dispatch.SessionKey, + opts.Dispatch.SessionScope, + opts.Dispatch.SessionAliases, + ) return al.contextManager.Clear(ctx, opts.SessionKey) } diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 4d6ba293..fb505d1e 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -3365,6 +3365,105 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { } } +func TestProcessMessage_ClearCommandClearsRoutedAgentSession(t *testing.T) { + workspace := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: filepath.Join(workspace, "default"), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + { + ID: "main", + Default: true, + Workspace: filepath.Join(workspace, "main"), + }, + { + ID: "support", + Workspace: filepath.Join(workspace, "support"), + }, + }, + Dispatch: &config.DispatchConfig{ + Rules: []config.DispatchRule{ + { + Name: "support-dingtalk", + Agent: "support", + When: config.DispatchSelector{ + Channel: "dingtalk", + }, + }, + }, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &countingMockProvider{response: "LLM reply"}) + mainAgent, ok := al.registry.GetAgent("main") + if !ok { + t.Fatal("expected main agent") + } + supportAgent, ok := al.registry.GetAgent("support") + if !ok { + t.Fatal("expected support agent") + } + + msg := testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "dingtalk", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "/clear", + }) + route, routedAgent, err := al.resolveMessageRoute(msg) + if err != nil { + t.Fatalf("resolveMessageRoute() error = %v", err) + } + if routedAgent != supportAgent { + t.Fatalf("routed agent = %s, want support", routedAgent.ID) + } + sessionKey := al.allocateRouteSession(route, msg).SessionKey + + mainHistory := []providers.Message{{Role: "user", Content: "main history"}} + supportHistory := []providers.Message{{Role: "user", Content: "support history"}} + mainAgent.Sessions.SetHistory(sessionKey, mainHistory) + mainAgent.Sessions.SetSummary(sessionKey, "main summary") + supportAgent.Sessions.SetHistory(sessionKey, supportHistory) + supportAgent.Sessions.SetSummary(sessionKey, "support summary") + + response, err := al.processMessage(context.Background(), msg) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Chat history cleared!" { + t.Fatalf("response = %q, want clear confirmation", response) + } + + if got := supportAgent.Sessions.GetHistory(sessionKey); len(got) != 0 { + t.Fatalf("support history len = %d, want 0", len(got)) + } + if got := supportAgent.Sessions.GetSummary(sessionKey); got != "" { + t.Fatalf("support summary = %q, want empty", got) + } + if got := mainAgent.Sessions.GetHistory(sessionKey); len(got) != len(mainHistory) { + t.Fatalf("main history len = %d, want %d", len(got), len(mainHistory)) + } else if got[0].Role != mainHistory[0].Role { + t.Fatalf("main history[0].Role = %q, want %q", got[0].Role, mainHistory[0].Role) + } else if got[0].Content != mainHistory[0].Content { + t.Fatalf("main history[0].Content = %q, want %q", got[0].Content, mainHistory[0].Content) + } + if got := mainAgent.Sessions.GetSummary(sessionKey); got != "main summary" { + t.Fatalf("main summary = %q, want %q", got, "main summary") + } +} + func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { diff --git a/pkg/agent/context_legacy.go b/pkg/agent/context_legacy.go index 908c3d85..eeaba3de 100644 --- a/pkg/agent/context_legacy.go +++ b/pkg/agent/context_legacy.go @@ -63,7 +63,9 @@ func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error } func (m *legacyContextManager) Clear(_ context.Context, sessionKey string) error { - agent := m.al.registry.GetDefaultAgent() + // Routed (non-default) agents keep history in their own session store, + // so resolve the owning agent instead of assuming the default one. + agent := m.al.agentForSession(sessionKey) if agent == nil || agent.Sessions == nil { return fmt.Errorf("sessions not initialized") } diff --git a/pkg/agent/context_manager_test.go b/pkg/agent/context_manager_test.go index 46e521be..bdccf4f6 100644 --- a/pkg/agent/context_manager_test.go +++ b/pkg/agent/context_manager_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "path/filepath" "strings" "sync" "sync/atomic" @@ -658,6 +659,91 @@ func TestIngestCalledDuringTurn(t *testing.T) { } } +func TestClearCommandRoutedAgentCallsContextManagerClear(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + mock := &trackingContextManager{} + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return mock, nil + } + if err := RegisterContextManager("clear_track_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + workspace := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: filepath.Join(workspace, "default"), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "clear_track_cm", + }, + List: []config.AgentConfig{ + { + ID: "main", + Default: true, + Workspace: filepath.Join(workspace, "main"), + }, + { + ID: "support", + Workspace: filepath.Join(workspace, "support"), + }, + }, + Dispatch: &config.DispatchConfig{ + Rules: []config.DispatchRule{ + { + Name: "support-dingtalk", + Agent: "support", + When: config.DispatchSelector{ + Channel: "dingtalk", + }, + }, + }, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &simpleMockProvider{response: "done"}) + if al.contextManager != mock { + t.Fatalf("expected mock context manager, got %T", al.contextManager) + } + + msg := testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "dingtalk", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "/clear", + }) + route, _, err := al.resolveMessageRoute(msg) + if err != nil { + t.Fatalf("resolveMessageRoute() error = %v", err) + } + sessionKey := al.allocateRouteSession(route, msg).SessionKey + + if _, err := al.processMessage(context.Background(), msg); err != nil { + t.Fatalf("processMessage() error = %v", err) + } + + if got := mock.clearCalls.Load(); got != 1 { + t.Fatalf("Clear calls = %d, want 1", got) + } + mock.mu.Lock() + gotKey := mock.lastClearKey + mock.mu.Unlock() + if gotKey != sessionKey { + t.Fatalf("Clear session key = %q, want %q", gotKey, sessionKey) + } +} + // --------------------------------------------------------------------------- // forceCompression edge cases (via legacy Compact) // --------------------------------------------------------------------------- @@ -712,10 +798,12 @@ type trackingContextManager struct { assembleCalls atomic.Int64 compactCalls atomic.Int64 ingestCalls atomic.Int64 + clearCalls atomic.Int64 mu sync.Mutex lastAssemble *AssembleRequest lastCompact *CompactRequest lastIngest *IngestRequest + lastClearKey string } func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { @@ -742,7 +830,13 @@ func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) e return nil } -func (m *trackingContextManager) Clear(_ context.Context, _ string) error { return nil } +func (m *trackingContextManager) Clear(_ context.Context, sessionKey string) error { + m.clearCalls.Add(1) + m.mu.Lock() + m.lastClearKey = sessionKey + m.mu.Unlock() + return nil +} // resetCMRegistry clears the global factory registry and returns a cleanup // function that restores the original state after the test. diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go index 6bd32c21..4e4c55fd 100644 --- a/pkg/agent/context_seahorse.go +++ b/pkg/agent/context_seahorse.go @@ -20,6 +20,7 @@ import ( type seahorseContextManager struct { engine *seahorse.Engine sessions session.SessionStore // for startup bootstrap + al *AgentLoop // for resolving the agent that owns a session } // newSeahorseContextManager creates a seahorse-backed ContextManager. @@ -47,6 +48,7 @@ func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager mgr := &seahorseContextManager{ engine: engine, sessions: agent.Sessions, + al: al, } // Register seahorse tools with the agent's tool registry @@ -160,10 +162,18 @@ func (m *seahorseContextManager) Clear(ctx context.Context, sessionKey string) e if err := m.engine.ClearSession(ctx, sessionKey); err != nil { return err } - if m.sessions != nil { - m.sessions.SetHistory(sessionKey, []providers.Message{}) - m.sessions.SetSummary(sessionKey, "") - return m.sessions.Save(sessionKey) + // The session may belong to a routed (non-default) agent whose JSONL + // store differs from the bootstrap store, so clear the owner's store. + sessions := m.sessions + if m.al != nil { + if agent := m.al.agentForSession(sessionKey); agent != nil && agent.Sessions != nil { + sessions = agent.Sessions + } + } + if sessions != nil { + sessions.SetHistory(sessionKey, []providers.Message{}) + sessions.SetSummary(sessionKey, "") + return sessions.Save(sessionKey) } return nil } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 8404531f..317a4930 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -7,7 +7,6 @@ import ( "encoding/json" "fmt" "io" - "log" "maps" "net/http" "net/url"