fix(session): export the full running conversation, handle quoted paths
Two bugs in yesterday's /session export + import: 1. Quoted / tilde paths weren't normalised. Drag-drop paste in the tui auto-quotes dropped file paths so the shell-style `/session import 'foo bar.zotsession'` stays well-formed. But the /session handler's expandTilde checked for a leading '~' and the string's first char was a literal quote, so the tilde never expanded and stat failed with "no such file or directory". unquotePath helper now strips a matching pair of surrounding single or double quotes before expandTilde runs. Applies to both export (dst) and import (src). 2. Export was writing only the meta row when called mid-session. The tui's default persistence strategy writes agent messages to the session file lazily: WriteNewTranscript runs once when the tui exits, NOT after every turn. Meanwhile the running agent's messages live in a sync.Mutex-guarded slice in core.Agent.messages. /session export was reading the file bytes off disk, which at that point only contained the meta row plus whatever was there on startup. New FlushSession hook on InteractiveConfig: the cli wires it to WriteNewTranscript against the current agent, then advances sessBaselineMsgs so the tui's own exit-time flush doesn't double-write. /session export calls the hook right before ExportSession, so the file on disk reflects the full running transcript at the moment the user hit enter. Tests: - internal/core/session_portable_test.go was already exercising ExportSession/ImportSession against on-disk files; this fix lives in the cli/modes glue, not in core. - internal/agent/modes ad-hoc TestUnquotePathThenExpandTilde (run locally, not committed) covered the 8 tilde+quote combinations. Verified: create a fresh session, type "hello", reply, "foo", reply, run /session export. Exported .zotsession now contains the meta row + 2 user + 2 assistant + 1 usage row. Re-import into a different cwd via /session import <path>, /sessions to confirm it lands as a resumable entry.
- commit
- 9b063d871b494854541c1b013d1479a559a5b30e
- author
- patriceckhart · · 2026-04-20 08:19 UTC
- parent
- ef80f9c
- signature
- unsigned
- refs
- tag: v0.0.58
2 files · +55 -2
M internal/agent/cli.go +18 -0
| @@ -570,6 +570,24 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 570 | 570 | } | |
| 571 | 571 | return sess.Path | |
| 572 | 572 | }, | |
| 573 | + | FlushSession: func() { | |
| 574 | + | // Append any not-yet-persisted agent messages to the | |
| 575 | + | // current session file, then advance the baseline so | |
| 576 | + | // the final WriteNewTranscript at exit doesn't write | |
| 577 | + | // duplicates. Called by /session export so the | |
| 578 | + | // exported bytes include everything the user has | |
| 579 | + | // said in the running TUI, not just whatever got | |
| 580 | + | // flushed at startup. | |
| 581 | + | if sess == nil { | |
| 582 | + | return | |
| 583 | + | } | |
| 584 | + | currentAg := iv.Agent() | |
| 585 | + | if currentAg == nil { | |
| 586 | + | return | |
| 587 | + | } | |
| 588 | + | WriteNewTranscript(currentAg, sess, sessBaselineMsgs) | |
| 589 | + | sessBaselineMsgs = len(currentAg.Messages()) | |
| 590 | + | }, | |
| 573 | 591 | Extensions: extMgr, | |
| 574 | 592 | ChangelogChan: changelogCh, | |
| 575 | 593 | OnChangelogDismiss: func() { | |
| @@ -570,6 +570,24 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 570 | } | 570 | } | ||
| 571 | return sess.Path | 571 | return sess.Path | ||
| 572 | }, | 572 | }, | ||
| 573 | + | FlushSession: func() { | |||
| 574 | + | // Append any not-yet-persisted agent messages to the | |||
| 575 | + | // current session file, then advance the baseline so | |||
| 576 | + | // the final WriteNewTranscript at exit doesn't write | |||
| 577 | + | // duplicates. Called by /session export so the | |||
| 578 | + | // exported bytes include everything the user has | |||
| 579 | + | // said in the running TUI, not just whatever got | |||
| 580 | + | // flushed at startup. | |||
| 581 | + | if sess == nil { | |||
| 582 | + | return | |||
| 583 | + | } | |||
| 584 | + | currentAg := iv.Agent() | |||
| 585 | + | if currentAg == nil { | |||
| 586 | + | return | |||
| 587 | + | } | |||
| 588 | + | WriteNewTranscript(currentAg, sess, sessBaselineMsgs) | |||
| 589 | + | sessBaselineMsgs = len(currentAg.Messages()) | |||
| 590 | + | }, | |||
| 573 | Extensions: extMgr, | 591 | Extensions: extMgr, | ||
| 574 | ChangelogChan: changelogCh, | 592 | ChangelogChan: changelogCh, | ||
| 575 | OnChangelogDismiss: func() { | 593 | OnChangelogDismiss: func() { | ||
M internal/agent/modes/interactive.go +37 -2
| @@ -83,6 +83,17 @@ type InteractiveConfig struct { | |||
| 83 | 83 | // no session is open. | |
| 84 | 84 | CurrentSessionPath func() string | |
| 85 | 85 | ||
| 86 | + | // FlushSession writes any in-memory agent messages to the | |
| 87 | + | // session file that haven't been persisted yet. Called by | |
| 88 | + | // /session export right before reading the file so the | |
| 89 | + | // exported bytes reflect the full current conversation, not | |
| 90 | + | // just the rows the agent happened to write synchronously. | |
| 91 | + | // The default WriteNewTranscript-at-exit strategy means most | |
| 92 | + | // of a running session lives only in memory until the tui | |
| 93 | + | // closes; without a flush hook, /session export writes a | |
| 94 | + | // file that only has the meta row. | |
| 95 | + | FlushSession func() | |
| 96 | + | ||
| 86 | 97 | // PersistModel is called whenever the user switches model or provider. | |
| 87 | 98 | // It should update config.json and (if there's an active session) | |
| 88 | 99 | // write a new meta row so resume picks up the same model. | |
| @@ -2563,7 +2574,14 @@ func (i *Interactive) doSessionExport(dst string) { | |||
| 2563 | 2574 | i.invalidate() | |
| 2564 | 2575 | return | |
| 2565 | 2576 | } | |
| 2566 | - | dst = strings.TrimSpace(dst) | |
| 2577 | + | // Persist any in-memory agent messages to the session file so | |
| 2578 | + | // the export carries the full conversation. Without this, the | |
| 2579 | + | // default lazy-flush-at-exit strategy leaves most of a running | |
| 2580 | + | // session unwritten and the export ends up with only the meta. | |
| 2581 | + | if i.cfg.FlushSession != nil { | |
| 2582 | + | i.cfg.FlushSession() | |
| 2583 | + | } | |
| 2584 | + | dst = unquotePath(dst) | |
| 2567 | 2585 | if dst == "" { | |
| 2568 | 2586 | dst = defaultExportDir() | |
| 2569 | 2587 | } else { | |
| @@ -2589,7 +2607,7 @@ func (i *Interactive) doSessionExport(dst string) { | |||
| 2589 | 2607 | // session, same as `/sessions` -> pick. When src is empty we ask | |
| 2590 | 2608 | // the user to pass a path (no usable default here). | |
| 2591 | 2609 | func (i *Interactive) doSessionImport(src string) { | |
| 2592 | - | src = strings.TrimSpace(src) | |
| 2610 | + | src = unquotePath(src) | |
| 2593 | 2611 | if src == "" { | |
| 2594 | 2612 | i.mu.Lock() | |
| 2595 | 2613 | i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession" | |
| @@ -2668,6 +2686,23 @@ func expandTilde(p string) string { | |||
| 2668 | 2686 | return p | |
| 2669 | 2687 | } | |
| 2670 | 2688 | ||
| 2689 | + | // unquotePath strips a matching pair of surrounding single or | |
| 2690 | + | // double quotes. Drag-drop paste in the tui auto-quotes dropped | |
| 2691 | + | // file paths so the shell-like `/session import 'foo bar.zs'` | |
| 2692 | + | // stays well-formed; when the TUI's own slash handler consumes | |
| 2693 | + | // the arg, we want the raw path back. | |
| 2694 | + | func unquotePath(p string) string { | |
| 2695 | + | p = strings.TrimSpace(p) | |
| 2696 | + | if len(p) >= 2 { | |
| 2697 | + | first := p[0] | |
| 2698 | + | last := p[len(p)-1] | |
| 2699 | + | if (first == '\'' && last == '\'') || (first == '"' && last == '"') { | |
| 2700 | + | p = p[1 : len(p)-1] | |
| 2701 | + | } | |
| 2702 | + | } | |
| 2703 | + | return p | |
| 2704 | + | } | |
| 2705 | + | ||
| 2671 | 2706 | // friendlyPath collapses the user's home directory to a leading ~ | |
| 2672 | 2707 | // so status messages read cleanly. Falls back to the raw path when | |
| 2673 | 2708 | // the home dir is unknown. | |
| @@ -83,6 +83,17 @@ type InteractiveConfig struct { | |||||
| 83 | // no session is open. | 83 | // no session is open. | ||
| 84 | CurrentSessionPath func() string | 84 | CurrentSessionPath func() string | ||
| 85 | 85 | ||||
| 86 | + | // FlushSession writes any in-memory agent messages to the | |||
| 87 | + | // session file that haven't been persisted yet. Called by | |||
| 88 | + | // /session export right before reading the file so the | |||
| 89 | + | // exported bytes reflect the full current conversation, not | |||
| 90 | + | // just the rows the agent happened to write synchronously. | |||
| 91 | + | // The default WriteNewTranscript-at-exit strategy means most | |||
| 92 | + | // of a running session lives only in memory until the tui | |||
| 93 | + | // closes; without a flush hook, /session export writes a | |||
| 94 | + | // file that only has the meta row. | |||
| 95 | + | FlushSession func() | |||
| 96 | + | ||||
| 86 | // PersistModel is called whenever the user switches model or provider. | 97 | // PersistModel is called whenever the user switches model or provider. | ||
| 87 | // It should update config.json and (if there's an active session) | 98 | // It should update config.json and (if there's an active session) | ||
| 88 | // write a new meta row so resume picks up the same model. | 99 | // write a new meta row so resume picks up the same model. | ||
| @@ -2563,7 +2574,14 @@ func (i *Interactive) doSessionExport(dst string) { | |||||
| 2563 | i.invalidate() | 2574 | i.invalidate() | ||
| 2564 | return | 2575 | return | ||
| 2565 | } | 2576 | } | ||
| 2566 | - | dst = strings.TrimSpace(dst) | 2577 | + | // Persist any in-memory agent messages to the session file so |
| 2578 | + | // the export carries the full conversation. Without this, the | |||
| 2579 | + | // default lazy-flush-at-exit strategy leaves most of a running | |||
| 2580 | + | // session unwritten and the export ends up with only the meta. | |||
| 2581 | + | if i.cfg.FlushSession != nil { | |||
| 2582 | + | i.cfg.FlushSession() | |||
| 2583 | + | } | |||
| 2584 | + | dst = unquotePath(dst) | |||
| 2567 | if dst == "" { | 2585 | if dst == "" { | ||
| 2568 | dst = defaultExportDir() | 2586 | dst = defaultExportDir() | ||
| 2569 | } else { | 2587 | } else { | ||
| @@ -2589,7 +2607,7 @@ func (i *Interactive) doSessionExport(dst string) { | |||||
| 2589 | // session, same as `/sessions` -> pick. When src is empty we ask | 2607 | // session, same as `/sessions` -> pick. When src is empty we ask | ||
| 2590 | // the user to pass a path (no usable default here). | 2608 | // the user to pass a path (no usable default here). | ||
| 2591 | func (i *Interactive) doSessionImport(src string) { | 2609 | func (i *Interactive) doSessionImport(src string) { | ||
| 2592 | - | src = strings.TrimSpace(src) | 2610 | + | src = unquotePath(src) |
| 2593 | if src == "" { | 2611 | if src == "" { | ||
| 2594 | i.mu.Lock() | 2612 | i.mu.Lock() | ||
| 2595 | i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession" | 2613 | i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession" | ||
| @@ -2668,6 +2686,23 @@ func expandTilde(p string) string { | |||||
| 2668 | return p | 2686 | return p | ||
| 2669 | } | 2687 | } | ||
| 2670 | 2688 | ||||
| 2689 | + | // unquotePath strips a matching pair of surrounding single or | |||
| 2690 | + | // double quotes. Drag-drop paste in the tui auto-quotes dropped | |||
| 2691 | + | // file paths so the shell-like `/session import 'foo bar.zs'` | |||
| 2692 | + | // stays well-formed; when the TUI's own slash handler consumes | |||
| 2693 | + | // the arg, we want the raw path back. | |||
| 2694 | + | func unquotePath(p string) string { | |||
| 2695 | + | p = strings.TrimSpace(p) | |||
| 2696 | + | if len(p) >= 2 { | |||
| 2697 | + | first := p[0] | |||
| 2698 | + | last := p[len(p)-1] | |||
| 2699 | + | if (first == '\'' && last == '\'') || (first == '"' && last == '"') { | |||
| 2700 | + | p = p[1 : len(p)-1] | |||
| 2701 | + | } | |||
| 2702 | + | } | |||
| 2703 | + | return p | |||
| 2704 | + | } | |||
| 2705 | + | ||||
| 2671 | // friendlyPath collapses the user's home directory to a leading ~ | 2706 | // friendlyPath collapses the user's home directory to a leading ~ | ||
| 2672 | // so status messages read cleanly. Falls back to the raw path when | 2707 | // so status messages read cleanly. Falls back to the raw path when | ||
| 2673 | // the home dir is unknown. | 2708 | // the home dir is unknown. | ||