session persistence: /save, /resume, cwd-scoped /sessions
- Add CWD field to PersistedSession for per-directory filtering - /sessions now shows only sessions matching current cwd (-a for all) - /save <name> creates a named checkpoint - /resume <id> restores a saved session (messages + task state) - Autosave (already existed) now persists cwd with each save
This commit is contained in:
parent
9d4818af8d
commit
8ae0fad8a3
|
|
@ -342,11 +342,13 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool {
|
|||
},
|
||||
|
||||
"/sessions": func(args []string) {
|
||||
allFlag := len(args) > 0 && args[0] == "-a"
|
||||
entries, err := os.ReadDir(s.sessionsDir)
|
||||
if err != nil {
|
||||
s.emit(infoEvent(fmt.Sprintf("sessions: %v", err)))
|
||||
return
|
||||
}
|
||||
cwd := s.CWD()
|
||||
found := false
|
||||
for i := len(entries) - 1; i >= 0; i-- {
|
||||
e := entries[i]
|
||||
|
|
@ -354,35 +356,85 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool {
|
|||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(e.Name(), ".json")
|
||||
data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name())
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
var ps PersistedSession
|
||||
if json.Unmarshal(data, &ps) != nil {
|
||||
continue
|
||||
}
|
||||
if !allFlag && ps.CWD != "" && ps.CWD != cwd {
|
||||
continue
|
||||
}
|
||||
marker := " "
|
||||
if id == s.sessionID {
|
||||
marker = "* "
|
||||
}
|
||||
label := id
|
||||
if data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name()); readErr == nil {
|
||||
var ps PersistedSession
|
||||
if json.Unmarshal(data, &ps) == nil {
|
||||
goal := ""
|
||||
for _, msg := range ps.Messages {
|
||||
if msg.Role == "user" {
|
||||
goal = msg.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(goal) > 60 {
|
||||
goal = goal[:60] + "..."
|
||||
}
|
||||
label = fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal)
|
||||
goal := ""
|
||||
for _, msg := range ps.Messages {
|
||||
if msg.Role == "user" {
|
||||
goal = msg.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
s.emit(infoEvent(marker + label))
|
||||
if len(goal) > 60 {
|
||||
goal = goal[:60] + "..."
|
||||
}
|
||||
s.emit(infoEvent(marker + fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal)))
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
s.emit(infoEvent("no sessions found in " + s.sessionsDir))
|
||||
s.emit(infoEvent("no sessions for " + cwd))
|
||||
}
|
||||
},
|
||||
|
||||
"/save": func(args []string) {
|
||||
if s.session == nil {
|
||||
s.emit(infoEvent("error: no active session"))
|
||||
return
|
||||
}
|
||||
if len(args) == 0 {
|
||||
s.emit(infoEvent("error: /save requires a name"))
|
||||
return
|
||||
}
|
||||
name := args[0]
|
||||
path := s.sessionsDir + "/" + name + ".json"
|
||||
if err := s.session.saveTo(path, name, s.agentName, s.CWD()); err != nil {
|
||||
s.emit(infoEvent("error: " + err.Error()))
|
||||
return
|
||||
}
|
||||
s.emit(infoEvent("saved: " + path))
|
||||
},
|
||||
|
||||
"/resume": func(args []string) {
|
||||
if len(args) == 0 {
|
||||
s.emit(infoEvent("error: /resume requires a session id or name"))
|
||||
return
|
||||
}
|
||||
if s.IsRunning() {
|
||||
s.emit(infoEvent("error: cannot resume while agent is running"))
|
||||
return
|
||||
}
|
||||
name := args[0]
|
||||
path := s.sessionsDir + "/" + name + ".json"
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
s.emit(infoEvent(fmt.Sprintf("error: %v", err)))
|
||||
return
|
||||
}
|
||||
var ps PersistedSession
|
||||
if err := json.Unmarshal(data, &ps); err != nil {
|
||||
s.emit(infoEvent(fmt.Sprintf("error: %v", err)))
|
||||
return
|
||||
}
|
||||
s.session = RestoreSession(ps.Messages)
|
||||
if ps.TaskState != nil {
|
||||
s.session.TaskState = ps.TaskState
|
||||
}
|
||||
s.emit(infoEvent(fmt.Sprintf("resumed session %s (%d messages)", name, len(ps.Messages))))
|
||||
},
|
||||
|
||||
"/cwd": func(args []string) {
|
||||
if len(args) == 0 {
|
||||
s.emit(infoEvent("cwd: " + s.CWD()))
|
||||
|
|
@ -407,7 +459,9 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool {
|
|||
lines := []string{
|
||||
"Available commands:",
|
||||
" /agents - list available agent configs",
|
||||
" /sessions - list saved sessions",
|
||||
" /sessions [-a] - list saved sessions (current cwd; -a for all)",
|
||||
" /save <name> - save session as a named checkpoint",
|
||||
" /resume <id> - resume a saved session",
|
||||
" /agent [name] - show or switch active agent",
|
||||
" /backend [type] - show current backend, or switch to <type>",
|
||||
" /model [name] - show current model, or switch to <name>",
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@ func (s *agent) saveSession() {
|
|||
return
|
||||
}
|
||||
path := s.sessionsDir + "/" + s.sessionID + ".json"
|
||||
if err := s.session.saveTo(path, s.sessionID, s.agentName); err != nil {
|
||||
if err := s.session.saveTo(path, s.sessionID, s.agentName, s.CWD()); err != nil {
|
||||
s.log.Error("session save: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1250,7 +1250,7 @@ func TestCommand_Sessions_Empty(t *testing.T) {
|
|||
evs := collectEvents(context.Background(), c, "/sessions")
|
||||
found := false
|
||||
for _, s := range byRole(evs, "info") {
|
||||
if strings.Contains(s, "no sessions found") {
|
||||
if strings.Contains(s, "no sessions") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
|
@ -2106,7 +2106,7 @@ func TestRestoreSession_RoundTrip(t *testing.T) {
|
|||
s.appendUserMessage("second message")
|
||||
s.messages = append(s.messages, backend.Message{Role: "assistant", Content: "reply"})
|
||||
|
||||
if err := s.saveTo(path, "test-id", "test-agent"); err != nil {
|
||||
if err := s.saveTo(path, "test-id", "test-agent", "/tmp/test"); err != nil {
|
||||
t.Fatalf("saveTo: %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ Be concise. Capture what another LLM needs to seamlessly continue.`
|
|||
type PersistedSession struct {
|
||||
ID string `json:"id"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
CWD string `json:"cwd,omitempty"`
|
||||
Messages []backend.Message `json:"messages"`
|
||||
TaskState *TaskState `json:"taskState,omitempty"`
|
||||
}
|
||||
|
|
@ -81,10 +82,11 @@ func (ts *TaskState) render() string {
|
|||
}
|
||||
|
||||
// SaveTo writes the full message history to path as JSON.
|
||||
func (s *Session) saveTo(path, id, agentName string) error {
|
||||
func (s *Session) saveTo(path, id, agentName, cwd string) error {
|
||||
ps := PersistedSession{
|
||||
ID: id,
|
||||
Agent: agentName,
|
||||
CWD: cwd,
|
||||
Messages: s.messages,
|
||||
TaskState: s.TaskState,
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue