Add non-destructive session reset command
This commit is contained in:
parent
2834db13de
commit
af7bc26a58
11 changed files with 411 additions and 17 deletions
|
|
@ -335,6 +335,28 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||||
return al.contextManager.Clear(ctx, opts.SessionKey)
|
return al.contextManager.Clear(ctx, opts.SessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rt.ResetSession = func(clear bool) (string, error) {
|
||||||
|
if opts == nil {
|
||||||
|
return "", fmt.Errorf("process options not available")
|
||||||
|
}
|
||||||
|
routeSessionKey := strings.TrimSpace(opts.Dispatch.RouteSessionKey)
|
||||||
|
if routeSessionKey == "" {
|
||||||
|
return "", fmt.Errorf("route session key not available")
|
||||||
|
}
|
||||||
|
if clear {
|
||||||
|
return "", al.clearSessionOverride(routeSessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSessionKey := buildResetSessionKey(agent.ID, routeSessionKey)
|
||||||
|
if nextSessionKey == "" {
|
||||||
|
return "", fmt.Errorf("failed to allocate reset session key")
|
||||||
|
}
|
||||||
|
if err := al.setSessionOverride(routeSessionKey, nextSessionKey); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return nextSessionKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
|
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
|
||||||
return al.askSideQuestion(ctx, agent, opts, question)
|
return al.askSideQuestion(ctx, agent, opts, question)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuat
|
||||||
allocation := al.allocateRouteSession(route, msg)
|
allocation := al.allocateRouteSession(route, msg)
|
||||||
|
|
||||||
return &continuationTarget{
|
return &continuationTarget{
|
||||||
SessionKey: resolveScopeKey(allocation.SessionKey, msg.SessionKey),
|
SessionKey: al.resolveEffectiveSessionKey(allocation.SessionKey, msg.SessionKey),
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -146,7 +146,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
// Resolve session key from the route allocation, while preserving explicit
|
// Resolve session key from the route allocation, while preserving explicit
|
||||||
// agent-scoped keys supplied by the caller.
|
// agent-scoped keys supplied by the caller.
|
||||||
scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey)
|
scopeKey := al.resolveEffectiveSessionKey(allocation.SessionKey, msg.SessionKey)
|
||||||
sessionKey := scopeKey
|
sessionKey := scopeKey
|
||||||
|
|
||||||
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||||
|
|
@ -169,13 +169,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
|
|
||||||
opts := processOptions{
|
opts := processOptions{
|
||||||
Dispatch: DispatchRequest{
|
Dispatch: DispatchRequest{
|
||||||
SessionKey: sessionKey,
|
RouteSessionKey: allocation.SessionKey,
|
||||||
SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...),
|
SessionKey: sessionKey,
|
||||||
InboundContext: cloneInboundContext(&msg.Context),
|
SessionAliases: buildSessionAliases(sessionKey, sessionAliasCandidates(allocation.SessionKey, sessionKey, allocation.SessionAliases, msg.SessionKey)...),
|
||||||
RouteResult: cloneResolvedRoute(&route),
|
InboundContext: cloneInboundContext(&msg.Context),
|
||||||
SessionScope: session.CloneScope(&allocation.Scope),
|
RouteResult: cloneResolvedRoute(&route),
|
||||||
UserMessage: msg.Content,
|
SessionScope: session.CloneScope(&allocation.Scope),
|
||||||
Media: append([]string(nil), msg.Media...),
|
UserMessage: msg.Content,
|
||||||
|
Media: append([]string(nil), msg.Media...),
|
||||||
},
|
},
|
||||||
SenderID: msg.SenderID,
|
SenderID: msg.SenderID,
|
||||||
SenderDisplayName: msg.Sender.DisplayName,
|
SenderDisplayName: msg.Sender.DisplayName,
|
||||||
|
|
|
||||||
|
|
@ -108,5 +108,5 @@ func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, stri
|
||||||
}
|
}
|
||||||
allocation := al.allocateRouteSession(route, msg)
|
allocation := al.allocateRouteSession(route, msg)
|
||||||
|
|
||||||
return resolveScopeKey(allocation.SessionKey, msg.SessionKey), agent.ID, true
|
return al.resolveEffectiveSessionKey(allocation.SessionKey, msg.SessionKey), agent.ID, true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -681,6 +681,178 @@ func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_ResetCommandStartsFreshSession(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
initialMsg := testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "first message",
|
||||||
|
})
|
||||||
|
if _, err := al.processMessage(context.Background(), initialMsg); err != nil {
|
||||||
|
t.Fatalf("initial processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
route, _, err := al.resolveMessageRoute(initialMsg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||||
|
}
|
||||||
|
allocation := al.allocateRouteSession(route, initialMsg)
|
||||||
|
routeSessionKey := allocation.SessionKey
|
||||||
|
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
originalHistory := defaultAgent.Sessions.GetHistory(routeSessionKey)
|
||||||
|
if len(originalHistory) == 0 {
|
||||||
|
t.Fatal("expected initial history in routed session")
|
||||||
|
}
|
||||||
|
|
||||||
|
resetReply, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "/reset",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reset processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resetReply, "Started a fresh session") {
|
||||||
|
t.Fatalf("reset reply = %q, want fresh-session confirmation", resetReply)
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideSessionKey := al.getSessionOverride(routeSessionKey)
|
||||||
|
if overrideSessionKey == "" || overrideSessionKey == routeSessionKey {
|
||||||
|
t.Fatalf("override session key = %q, want distinct session key", overrideSessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "second message",
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatalf("second processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotOriginalHistory := defaultAgent.Sessions.GetHistory(routeSessionKey)
|
||||||
|
if len(gotOriginalHistory) != len(originalHistory) {
|
||||||
|
t.Fatalf("original history len = %d, want preserved len %d", len(gotOriginalHistory), len(originalHistory))
|
||||||
|
}
|
||||||
|
|
||||||
|
resetHistory := defaultAgent.Sessions.GetHistory(overrideSessionKey)
|
||||||
|
if len(resetHistory) == 0 {
|
||||||
|
t.Fatal("expected fresh session history after /reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_ResetClearRestoresDefaultSession(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
initialMsg := testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "before reset",
|
||||||
|
})
|
||||||
|
if _, err := al.processMessage(context.Background(), initialMsg); err != nil {
|
||||||
|
t.Fatalf("initial processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
route, _, err := al.resolveMessageRoute(initialMsg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||||
|
}
|
||||||
|
allocation := al.allocateRouteSession(route, initialMsg)
|
||||||
|
routeSessionKey := allocation.SessionKey
|
||||||
|
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||||
|
originalHistoryLen := len(defaultAgent.Sessions.GetHistory(routeSessionKey))
|
||||||
|
|
||||||
|
if _, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "/reset",
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatalf("reset processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
overrideSessionKey := al.getSessionOverride(routeSessionKey)
|
||||||
|
if overrideSessionKey == "" {
|
||||||
|
t.Fatal("expected override session key after /reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "during reset",
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatalf("during-reset processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
overrideHistoryLen := len(defaultAgent.Sessions.GetHistory(overrideSessionKey))
|
||||||
|
if overrideHistoryLen == 0 {
|
||||||
|
t.Fatal("expected override history after reset message")
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReply, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "/reset clear",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reset clear processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(clearReply, "Soft reset cleared") {
|
||||||
|
t.Fatalf("reset clear reply = %q, want clear confirmation", clearReply)
|
||||||
|
}
|
||||||
|
if got := al.getSessionOverride(routeSessionKey); got != "" {
|
||||||
|
t.Fatalf("override session key after clear = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "telegram:123",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "after clear",
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatalf("after-clear processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotOriginalHistoryLen := len(defaultAgent.Sessions.GetHistory(routeSessionKey))
|
||||||
|
if gotOriginalHistoryLen <= originalHistoryLen {
|
||||||
|
t.Fatalf("original history len = %d, want > %d after reset clear", gotOriginalHistoryLen, originalHistoryLen)
|
||||||
|
}
|
||||||
|
gotOverrideHistoryLen := len(defaultAgent.Sessions.GetHistory(overrideSessionKey))
|
||||||
|
if gotOverrideHistoryLen != overrideHistoryLen {
|
||||||
|
t.Fatalf("override history len = %d, want preserved len %d after reset clear", gotOverrideHistoryLen, overrideHistoryLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
|
func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
skillDir := filepath.Join(tmpDir, "skills", "shell")
|
skillDir := filepath.Join(tmpDir, "skills", "shell")
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,14 @@ import (
|
||||||
// DispatchRequest is the normalized runtime input passed into the agent loop
|
// DispatchRequest is the normalized runtime input passed into the agent loop
|
||||||
// after routing and session allocation have completed.
|
// after routing and session allocation have completed.
|
||||||
type DispatchRequest struct {
|
type DispatchRequest struct {
|
||||||
SessionKey string
|
RouteSessionKey string
|
||||||
SessionAliases []string
|
SessionKey string
|
||||||
InboundContext *bus.InboundContext
|
SessionAliases []string
|
||||||
RouteResult *routing.ResolvedRoute
|
InboundContext *bus.InboundContext
|
||||||
SessionScope *session.SessionScope
|
RouteResult *routing.ResolvedRoute
|
||||||
UserMessage string
|
SessionScope *session.SessionScope
|
||||||
Media []string
|
UserMessage string
|
||||||
|
Media []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r DispatchRequest) Channel() string {
|
func (r DispatchRequest) Channel() string {
|
||||||
|
|
@ -63,6 +64,9 @@ func normalizeProcessOptionsInPlace(opts *processOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeProcessOptions(opts processOptions) processOptions {
|
func normalizeProcessOptions(opts processOptions) processOptions {
|
||||||
|
if opts.Dispatch.RouteSessionKey == "" {
|
||||||
|
opts.Dispatch.RouteSessionKey = strings.TrimSpace(opts.SessionKey)
|
||||||
|
}
|
||||||
if opts.Dispatch.SessionKey == "" {
|
if opts.Dispatch.SessionKey == "" {
|
||||||
opts.Dispatch.SessionKey = strings.TrimSpace(opts.SessionKey)
|
opts.Dispatch.SessionKey = strings.TrimSpace(opts.SessionKey)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
pkg/agent/session_reset.go
Normal file
60
pkg/agent/session_reset.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildResetSessionKey(agentID, routeSessionKey string) string {
|
||||||
|
alias := fmt.Sprintf(
|
||||||
|
"agent:%s:reset:%s:%d",
|
||||||
|
strings.ToLower(strings.TrimSpace(agentID)),
|
||||||
|
strings.ToLower(strings.TrimSpace(routeSessionKey)),
|
||||||
|
time.Now().UnixNano(),
|
||||||
|
)
|
||||||
|
return session.BuildOpaqueSessionKey(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) getSessionOverride(routeSessionKey string) string {
|
||||||
|
if al == nil || al.state == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return al.state.GetSessionOverride(routeSessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) setSessionOverride(routeSessionKey, sessionKey string) error {
|
||||||
|
if al == nil || al.state == nil {
|
||||||
|
return fmt.Errorf("state manager not initialized")
|
||||||
|
}
|
||||||
|
return al.state.SetSessionOverride(routeSessionKey, sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) clearSessionOverride(routeSessionKey string) error {
|
||||||
|
if al == nil || al.state == nil {
|
||||||
|
return fmt.Errorf("state manager not initialized")
|
||||||
|
}
|
||||||
|
return al.state.ClearSessionOverride(routeSessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) resolveEffectiveSessionKey(routeSessionKey, msgSessionKey string) string {
|
||||||
|
if isExplicitSessionKey(msgSessionKey) {
|
||||||
|
return msgSessionKey
|
||||||
|
}
|
||||||
|
if override := al.getSessionOverride(routeSessionKey); override != "" {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
return routeSessionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionAliasCandidates(routeSessionKey, effectiveSessionKey string, routeAliases []string, msgSessionKey string) []string {
|
||||||
|
if isExplicitSessionKey(msgSessionKey) {
|
||||||
|
return []string{msgSessionKey}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(routeSessionKey) == strings.TrimSpace(effectiveSessionKey) {
|
||||||
|
return routeAliases
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition {
|
||||||
btwCommand(),
|
btwCommand(),
|
||||||
switchCommand(),
|
switchCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
|
resetCommand(),
|
||||||
clearCommand(),
|
clearCommand(),
|
||||||
contextCommand(),
|
contextCommand(),
|
||||||
subagentsCommand(),
|
subagentsCommand(),
|
||||||
|
|
|
||||||
38
pkg/commands/cmd_reset.go
Normal file
38
pkg/commands/cmd_reset.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resetCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "reset",
|
||||||
|
Description: "Start a fresh session without deleting stored history",
|
||||||
|
Usage: "/reset [clear|off]",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ResetSession == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
arg := strings.ToLower(strings.TrimSpace(nthToken(req.Text, 1)))
|
||||||
|
clearOverride := arg == "clear" || arg == "off"
|
||||||
|
if arg != "" && !clearOverride {
|
||||||
|
return req.Reply("Usage: /reset [clear|off]")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey, err := rt.ResetSession(clearOverride)
|
||||||
|
if err != nil {
|
||||||
|
return req.Reply("Failed to reset session: " + err.Error())
|
||||||
|
}
|
||||||
|
if clearOverride {
|
||||||
|
return req.Reply("Soft reset cleared. Future messages will use the default routed session again.")
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf(
|
||||||
|
"Started a fresh session. Previous history was preserved. New session key: %s",
|
||||||
|
sessionKey,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,7 @@ type Runtime struct {
|
||||||
GetContextStats func() *ContextStats
|
GetContextStats func() *ContextStats
|
||||||
SwitchModel func(value string) (oldModel string, err error)
|
SwitchModel func(value string) (oldModel string, err error)
|
||||||
SwitchChannel func(value string) error
|
SwitchChannel func(value string) error
|
||||||
|
ResetSession func(clear bool) (sessionKey string, err error)
|
||||||
ClearHistory func() error
|
ClearHistory func() error
|
||||||
ReloadConfig func() error
|
ReloadConfig func() error
|
||||||
StopActiveTurn func() (StopResult, error)
|
StopActiveTurn func() (StopResult, error)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -21,6 +22,10 @@ type State struct {
|
||||||
// LastChatID is the last chat ID used for communication
|
// LastChatID is the last chat ID used for communication
|
||||||
LastChatID string `json:"last_chat_id,omitempty"`
|
LastChatID string `json:"last_chat_id,omitempty"`
|
||||||
|
|
||||||
|
// SessionOverrides maps the default routed session key for a conversation
|
||||||
|
// onto an explicit replacement session key created by a soft reset.
|
||||||
|
SessionOverrides map[string]string `json:"session_overrides,omitempty"`
|
||||||
|
|
||||||
// Timestamp is the last time this state was updated
|
// Timestamp is the last time this state was updated
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +113,66 @@ func (sm *Manager) SetLastChatID(chatID string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionOverride persists a replacement session key for a routed session.
|
||||||
|
func (sm *Manager) SetSessionOverride(routeSessionKey, sessionKey string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
routeSessionKey = strings.TrimSpace(routeSessionKey)
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if routeSessionKey == "" || sessionKey == "" {
|
||||||
|
return fmt.Errorf("route session key and session key are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.state.SessionOverrides == nil {
|
||||||
|
sm.state.SessionOverrides = make(map[string]string)
|
||||||
|
}
|
||||||
|
sm.state.SessionOverrides[routeSessionKey] = sessionKey
|
||||||
|
sm.state.Timestamp = time.Now()
|
||||||
|
|
||||||
|
if err := sm.saveAtomic(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save state atomically: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSessionOverride removes a previously persisted replacement session key.
|
||||||
|
func (sm *Manager) ClearSessionOverride(routeSessionKey string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
routeSessionKey = strings.TrimSpace(routeSessionKey)
|
||||||
|
if routeSessionKey == "" {
|
||||||
|
return fmt.Errorf("route session key is required")
|
||||||
|
}
|
||||||
|
if len(sm.state.SessionOverrides) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(sm.state.SessionOverrides, routeSessionKey)
|
||||||
|
if len(sm.state.SessionOverrides) == 0 {
|
||||||
|
sm.state.SessionOverrides = nil
|
||||||
|
}
|
||||||
|
sm.state.Timestamp = time.Now()
|
||||||
|
|
||||||
|
if err := sm.saveAtomic(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save state atomically: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionOverride returns the replacement session key for a routed session.
|
||||||
|
func (sm *Manager) GetSessionOverride(routeSessionKey string) string {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
if len(sm.state.SessionOverrides) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return sm.state.SessionOverrides[strings.TrimSpace(routeSessionKey)]
|
||||||
|
}
|
||||||
|
|
||||||
// GetLastChannel returns the last channel from the state.
|
// GetLastChannel returns the last channel from the state.
|
||||||
func (sm *Manager) GetLastChannel() string {
|
func (sm *Manager) GetLastChannel() string {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,36 @@ func TestNewManager_EmptyWorkspace(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionOverridesPersist(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
sm := NewManager(tmpDir)
|
||||||
|
if err := sm.SetSessionOverride("route-session", "reset-session"); err != nil {
|
||||||
|
t.Fatalf("SetSessionOverride failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := sm.GetSessionOverride("route-session"); got != "reset-session" {
|
||||||
|
t.Fatalf("GetSessionOverride() = %q, want %q", got, "reset-session")
|
||||||
|
}
|
||||||
|
|
||||||
|
sm2 := NewManager(tmpDir)
|
||||||
|
if got := sm2.GetSessionOverride("route-session"); got != "reset-session" {
|
||||||
|
t.Fatalf("persisted GetSessionOverride() = %q, want %q", got, "reset-session")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sm2.ClearSessionOverride("route-session"); err != nil {
|
||||||
|
t.Fatalf("ClearSessionOverride failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := sm2.GetSessionOverride("route-session"); got != "" {
|
||||||
|
t.Fatalf("GetSessionOverride() after clear = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm3 := NewManager(tmpDir)
|
||||||
|
if got := sm3.GetSessionOverride("route-session"); got != "" {
|
||||||
|
t.Fatalf("persisted GetSessionOverride() after clear = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) {
|
func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) {
|
||||||
if os.Getenv("BE_CRASHER") == "1" {
|
if os.Getenv("BE_CRASHER") == "1" {
|
||||||
tmpDir := os.Getenv("CRASH_DIR")
|
tmpDir := os.Getenv("CRASH_DIR")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue