From 8fc47622c8f83db3effb2b720279170571efec4c Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Sun, 2 Aug 2026 22:34:35 +0200 Subject: [PATCH] Persist renamed sessions and profiles --- agent/agent.go | 12 ++++++++---- agent/commands.go | 8 ++++++++ agent/history.go | 25 ++++++++++++++----------- fs/agentfiles.go | 2 ++ fs/lifecycle.go | 3 +++ fs/persist.go | 22 ++++++++++++++++------ session/session.go | 4 ++-- 7 files changed, 53 insertions(+), 23 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index aa70d2b..48ce522 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -363,11 +363,15 @@ func (ag *Agent) wireToolsChanged() { } // SaveFull persists the full session state (history + metadata) to the given path. -func (ag *Agent) SaveFull(path, sessionID, cwd, remote string) error { - if ag.history == nil { - return fmt.Errorf("no active session") +func (ag *Agent) SaveFull(path, sessionID, sessionName, cwd, remote string) error { + // Switching profiles intentionally clears the conversation history, but + // the profile and display name still need to be persisted. Save an empty + // history in that case rather than dropping the entire metadata snapshot. + history := ag.history + if history == nil { + history = &History{} } - return ag.history.saveToFull(path, sessionID, ag.Name(), ag.profile, + return history.saveToFull(path, sessionID, sessionName, ag.Name(), ag.profile, ag.runtime.Backend.Name(), ag.runtime.Backend.Model(), cwd, remote) } diff --git a/agent/commands.go b/agent/commands.go index c76ac2b..01a9e38 100644 --- a/agent/commands.go +++ b/agent/commands.go @@ -204,6 +204,14 @@ func (ag *Agent) cmdAgent(_ context.Context, args []string) { ag.profile = name ag.history = nil ag.wireToolsChanged() + // Profile switches clear history, so mark the session dirty and flush its + // metadata now rather than waiting for a future turn (which may never occur). + if ag.saveSession != nil { + ag.saveSession() + } + if ag.flushSave != nil { + ag.flushSave() + } ag.notifyChange() for _, msg := range rt.Messages { ag.emit(infoEvent(msg)) diff --git a/agent/history.go b/agent/history.go index aed4ada..2855a19 100644 --- a/agent/history.go +++ b/agent/history.go @@ -36,15 +36,17 @@ Be concise. Capture what another LLM needs to seamlessly continue.` // PersistedAgent is the on-disk format for a saved session. type PersistedAgent struct { - ID string `json:"id"` - Agent string `json:"agent,omitempty"` - Profile string `json:"profile,omitempty"` - Backend string `json:"backend,omitempty"` - Model string `json:"model,omitempty"` - CWD string `json:"cwd,omitempty"` - Remote string `json:"remote,omitempty"` - Messages []backend.Message `json:"messages"` - TaskState *TaskState `json:"taskState,omitempty"` + ID string `json:"id"` + // SessionName is the mutable display name of the containing session. + SessionName string `json:"sessionName,omitempty"` + Agent string `json:"agent,omitempty"` + Profile string `json:"profile,omitempty"` + Backend string `json:"backend,omitempty"` + Model string `json:"model,omitempty"` + CWD string `json:"cwd,omitempty"` + Remote string `json:"remote,omitempty"` + Messages []backend.Message `json:"messages"` + TaskState *TaskState `json:"taskState,omitempty"` // Usage and cost tracking, persisted across restarts. TotalInputTokens int `json:"totalInputTokens,omitempty"` TotalCachedInputTokens int `json:"totalCachedInputTokens,omitempty"` @@ -97,7 +99,7 @@ func (ts *TaskState) render() string { // saveTo writes the full message history to path as JSON. func (s *History) saveTo(path, id, displayName, cwd string) error { - return s.saveToFull(path, id, displayName, "", "", "", cwd, "") + return s.saveToFull(path, id, "", displayName, "", "", "", cwd, "") } // saveToFull writes session state including backend/model info. @@ -118,9 +120,10 @@ func sanitizeMessages(msgs []backend.Message) []backend.Message { return out } -func (s *History) saveToFull(path, id, displayName, profile, backendName, modelName, cwd, remote string) error { +func (s *History) saveToFull(path, id, sessionName, displayName, profile, backendName, modelName, cwd, remote string) error { ps := PersistedAgent{ ID: id, + SessionName: sessionName, Agent: displayName, Profile: profile, Backend: backendName, diff --git a/fs/agentfiles.go b/fs/agentfiles.go index 6afe4f2..0d0a765 100644 --- a/fs/agentfiles.go +++ b/fs/agentfiles.go @@ -265,6 +265,7 @@ func listAgents(ctx HandlerCtx) ([]FsNodeDecl, error) { return nil } a.SetName(newName) + persistSession(globalRootState, s.Name()) return nil }), ), @@ -281,6 +282,7 @@ func listAgents(ctx HandlerCtx) ([]FsNodeDecl, error) { ) agentDir.Rename = func(newName string) error { a.SetName(newName) + persistSession(globalRootState, s.Name()) return nil } out = append(out, agentDir) diff --git a/fs/lifecycle.go b/fs/lifecycle.go index 2fc0eb8..e793dc8 100644 --- a/fs/lifecycle.go +++ b/fs/lifecycle.go @@ -186,6 +186,9 @@ func Rename(rs *rootState, old, new string) error { rs.sessions[new] = sess delete(rs.sessions, old) rs.mu.Unlock() + // Persist the updated session and agent display names immediately. The + // agent's own autosave does not know the fs-level session display name. + persistSession(rs, new) rs.cfg.Log.Info("renamed session %s -> %s", old, new) PostEvent("S rename session/" + old + " session/" + new) return nil diff --git a/fs/persist.go b/fs/persist.go index 26184e3..dada1a6 100644 --- a/fs/persist.go +++ b/fs/persist.go @@ -45,7 +45,8 @@ func persistSession(rs *rootState, id string) { dir := activeSessionsDir(rs) os.MkdirAll(dir, 0700) path := filepath.Join(dir, sess.id+".json") - if err := sess.Core.SaveSession(path); err != nil { + ag := sess.Core.Agent() + if err := ag.SaveFull(path, sess.id, sess.Name(), sess.Core.CWD(), ""); err != nil { rs.cfg.Log.Error("persist session %s: %v", id, err) } } @@ -114,12 +115,13 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error { if cwd == "" { cwd, _ = os.Getwd() } + // Profile and agent display name are independent persisted fields. Never + // use the agent name (or its immutable ID) as a profile fallback: doing so + // makes restored sessions report an ID such as "eda74c4e8fb4" as the + // active profile. profile := ps.Profile if profile == "" { - profile = ps.Agent - if profile == "" { - profile = "default" - } + profile = "default" } sessID := ps.ID @@ -265,6 +267,9 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error { sessionCtx, sessionCancel := context.WithCancel(rs.cfg.Ctx) sess := NewSession(sessID, core, sessionCtx, sessionCancel) + if ps.SessionName != "" { + sess.SetName(ps.SessionName) + } sessPtr = sess sess.uname = uname sess.SetAgentLog(uname, al) @@ -302,6 +307,11 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error { } } rs.cfg.Log.Info("restored session %s: cfg=%v autoload=%d tools=%d (backend=%s model=%s agent=%s)", - sessID, cfg != nil, func() int { if cfg != nil { return len(cfg.AutoLoad) }; return -1 }(), toolCount, backendName, modelName, profile) + sessID, cfg != nil, func() int { + if cfg != nil { + return len(cfg.AutoLoad) + } + return -1 + }(), toolCount, backendName, modelName, profile) return nil } diff --git a/session/session.go b/session/session.go index ad2ddfd..b5affdb 100644 --- a/session/session.go +++ b/session/session.go @@ -382,7 +382,7 @@ func (a *Session) flushSave() { return } if ag := a.Agent(); ag != nil { - if err := ag.SaveFull(path, a.id, a.CWD(), a.remote); err != nil { + if err := ag.SaveFull(path, a.id, "", a.CWD(), a.remote); err != nil { a.log.Error("session save: %v", err) } } @@ -392,7 +392,7 @@ func (a *Session) flushSave() { // Returns nil if there is no agent (empty session). func (a *Session) SaveSession(path string) error { if ag := a.Agent(); ag != nil { - return ag.SaveFull(path, a.id, a.CWD(), a.remote) + return ag.SaveFull(path, a.id, "", a.CWD(), a.remote) } return nil }