Merge pull request #3224 from Ethan1918/fix/clear-routed-agent-session

fix(agent): clear routed agent session
This commit is contained in:
Mauro
2026-07-04 12:57:35 +02:00
committed by GitHub
6 changed files with 220 additions and 7 deletions
+9
View File
@@ -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)
}
+99
View File
@@ -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 {
+3 -1
View File
@@ -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")
}
+95 -1
View File
@@ -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.
+14 -4
View File
@@ -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
}
-1
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"maps"
"net/http"
"net/url"