Persist renamed sessions and profiles

This commit is contained in:
Ollie Agent 2026-08-02 22:34:35 +02:00
parent 39ce817f51
commit 8fc47622c8
7 changed files with 53 additions and 23 deletions

View File

@ -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)
}

View File

@ -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))

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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
}

View File

@ -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
}