Fix interactive session resume
- commit
- 92ac3e34cb24b63b72d91c70fe7b71c359766f23
- author
- patriceckhart · · 2026-07-29 07:28 UTC
- parent
- d3084cc
- signature
- unsigned
5 files · +139 -14
M docs/zotfiles.md +1 -1
| @@ -471,7 +471,7 @@ Sessions created by a zotfile are isolated from ordinary zot sessions and from o | |||
| 471 | 471 | $ZOT_HOME/sessions/agents/<name>/ | |
| 472 | 472 | ``` | |
| 473 | 473 | ||
| 474 | - | Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. | |
| 474 | + | Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root. | |
| 475 | 475 | ||
| 476 | 476 | ## Commands | |
| 477 | 477 | ||
| @@ -471,7 +471,7 @@ Sessions created by a zotfile are isolated from ordinary zot sessions and from o | |||||
| 471 | $ZOT_HOME/sessions/agents/<name>/ | 471 | $ZOT_HOME/sessions/agents/<name>/ | ||
| 472 | ``` | 472 | ``` | ||
| 473 | 473 | ||||
| 474 | - | Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. | 474 | + | Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root. |
| 475 | 475 | ||||
| 476 | ## Commands | 476 | ## Commands | ||
| 477 | 477 | ||||
M packages/agent/cli.go +20 -6
| @@ -122,11 +122,19 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool { | |||
| 122 | 122 | }) | |
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | - | // fanoutAgentEvent translates a core.AgentEvent into the wire-format | |
| 126 | - | // EventFromHost and pushes it through the extension manager. Only | |
| 127 | - | // the events that have a clear extension-facing meaning are | |
| 128 | - | // forwarded; internal-only ones (text_delta, tool_progress) are | |
| 129 | - | // dropped to keep the per-extension stream sane. | |
| 125 | + | // liveInteractiveAgent returns the agent currently owned by the TUI. The | |
| 126 | + | // fallback is only used before the Interactive has been constructed. | |
| 127 | + | // | |
| 128 | + | // Agent rebuilds, such as cross-provider model switches, replace the TUI's | |
| 129 | + | // agent without replacing the startup pointer held by runInteractive. Session | |
| 130 | + | // operations must therefore resolve the live agent at the time of the action. | |
| 131 | + | func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Agent { | |
| 132 | + | if iv != nil { | |
| 133 | + | return iv.Agent() | |
| 134 | + | } | |
| 135 | + | return fallback | |
| 136 | + | } | |
| 137 | + | ||
| 130 | 138 | func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message { | |
| 131 | 139 | if keepTail <= 0 || len(msgs) <= keepTail { | |
| 132 | 140 | return provider.RepairOrphanedToolResults(msgs) | |
| @@ -147,6 +155,11 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes | |||
| 147 | 155 | return provider.RepairOrphanedToolResults(out) | |
| 148 | 156 | } | |
| 149 | 157 | ||
| 158 | + | // fanoutAgentEvent translates a core.AgentEvent into the wire-format | |
| 159 | + | // EventFromHost and pushes it through the extension manager. Only | |
| 160 | + | // the events that have a clear extension-facing meaning are | |
| 161 | + | // forwarded; internal-only ones (text_delta, tool_progress) are | |
| 162 | + | // dropped to keep the per-extension stream sane. | |
| 150 | 163 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |
| 151 | 164 | if mgr == nil { | |
| 152 | 165 | return | |
| @@ -732,7 +745,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 732 | 745 | // loadSession replaces the current session with the one at path and | |
| 733 | 746 | // hands its messages to the agent. Used by the /sessions picker. | |
| 734 | 747 | loadSession := func(path string) error { | |
| 735 | - | currentAg := ag // captured | |
| 748 | + | currentAg := liveInteractiveAgent(iv, ag) | |
| 736 | 749 | if currentAg == nil { | |
| 737 | 750 | return fmt.Errorf("no agent running; log in first") | |
| 738 | 751 | } | |
| @@ -1020,6 +1033,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1020 | 1033 | StartupSkillNames: startupSkillNames(startupSkills), | |
| 1021 | 1034 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 1022 | 1035 | ZotHome: ZotHome(), | |
| 1036 | + | SessionsRoot: agentSessionsRoot(ZotHome(), args), | |
| 1023 | 1037 | Version: version, | |
| 1024 | 1038 | UpdateInfoChan: updateCh, | |
| 1025 | 1039 | Sandbox: sharedSandbox, | |
| @@ -122,11 +122,19 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool { | |||||
| 122 | }) | 122 | }) | ||
| 123 | } | 123 | } | ||
| 124 | 124 | ||||
| 125 | - | // fanoutAgentEvent translates a core.AgentEvent into the wire-format | 125 | + | // liveInteractiveAgent returns the agent currently owned by the TUI. The |
| 126 | - | // EventFromHost and pushes it through the extension manager. Only | 126 | + | // fallback is only used before the Interactive has been constructed. |
| 127 | - | // the events that have a clear extension-facing meaning are | 127 | + | // |
| 128 | - | // forwarded; internal-only ones (text_delta, tool_progress) are | 128 | + | // Agent rebuilds, such as cross-provider model switches, replace the TUI's |
| 129 | - | // dropped to keep the per-extension stream sane. | 129 | + | // agent without replacing the startup pointer held by runInteractive. Session |
| 130 | + | // operations must therefore resolve the live agent at the time of the action. | |||
| 131 | + | func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Agent { | |||
| 132 | + | if iv != nil { | |||
| 133 | + | return iv.Agent() | |||
| 134 | + | } | |||
| 135 | + | return fallback | |||
| 136 | + | } | |||
| 137 | + | ||||
| 130 | func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message { | 138 | func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message { | ||
| 131 | if keepTail <= 0 || len(msgs) <= keepTail { | 139 | if keepTail <= 0 || len(msgs) <= keepTail { | ||
| 132 | return provider.RepairOrphanedToolResults(msgs) | 140 | return provider.RepairOrphanedToolResults(msgs) | ||
| @@ -147,6 +155,11 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes | |||||
| 147 | return provider.RepairOrphanedToolResults(out) | 155 | return provider.RepairOrphanedToolResults(out) | ||
| 148 | } | 156 | } | ||
| 149 | 157 | ||||
| 158 | + | // fanoutAgentEvent translates a core.AgentEvent into the wire-format | |||
| 159 | + | // EventFromHost and pushes it through the extension manager. Only | |||
| 160 | + | // the events that have a clear extension-facing meaning are | |||
| 161 | + | // forwarded; internal-only ones (text_delta, tool_progress) are | |||
| 162 | + | // dropped to keep the per-extension stream sane. | |||
| 150 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | 163 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | ||
| 151 | if mgr == nil { | 164 | if mgr == nil { | ||
| 152 | return | 165 | return | ||
| @@ -732,7 +745,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 732 | // loadSession replaces the current session with the one at path and | 745 | // loadSession replaces the current session with the one at path and | ||
| 733 | // hands its messages to the agent. Used by the /sessions picker. | 746 | // hands its messages to the agent. Used by the /sessions picker. | ||
| 734 | loadSession := func(path string) error { | 747 | loadSession := func(path string) error { | ||
| 735 | - | currentAg := ag // captured | 748 | + | currentAg := liveInteractiveAgent(iv, ag) |
| 736 | if currentAg == nil { | 749 | if currentAg == nil { | ||
| 737 | return fmt.Errorf("no agent running; log in first") | 750 | return fmt.Errorf("no agent running; log in first") | ||
| 738 | } | 751 | } | ||
| @@ -1020,6 +1033,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1020 | StartupSkillNames: startupSkillNames(startupSkills), | 1033 | StartupSkillNames: startupSkillNames(startupSkills), | ||
| 1021 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | 1034 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | ||
| 1022 | ZotHome: ZotHome(), | 1035 | ZotHome: ZotHome(), | ||
| 1036 | + | SessionsRoot: agentSessionsRoot(ZotHome(), args), | |||
| 1023 | Version: version, | 1037 | Version: version, | ||
| 1024 | UpdateInfoChan: updateCh, | 1038 | UpdateInfoChan: updateCh, | ||
| 1025 | Sandbox: sharedSandbox, | 1039 | Sandbox: sharedSandbox, | ||
A packages/agent/cli_session_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/agent/modes" | |
| 7 | + | "github.com/patriceckhart/zot/packages/core" | |
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) { | |
| 12 | + | startup := core.NewAgent(nil, "startup-model", "", nil) | |
| 13 | + | startup.SetMessages([]provider.Message{{ | |
| 14 | + | Role: provider.RoleUser, | |
| 15 | + | Content: []provider.Content{provider.TextBlock{Text: "startup transcript"}}, | |
| 16 | + | }}) | |
| 17 | + | ||
| 18 | + | replacement := core.NewAgent(nil, "replacement-model", "", nil) | |
| 19 | + | iv := modes.NewInteractive(modes.InteractiveConfig{Agent: replacement}) | |
| 20 | + | ||
| 21 | + | resumed := []provider.Message{{ | |
| 22 | + | Role: provider.RoleUser, | |
| 23 | + | Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}}, | |
| 24 | + | }} | |
| 25 | + | liveInteractiveAgent(iv, startup).SetMessages(resumed) | |
| 26 | + | ||
| 27 | + | if got := firstMessageText(replacement.Messages()); got != "resumed transcript" { | |
| 28 | + | t.Fatalf("replacement agent transcript = %q, want resumed transcript", got) | |
| 29 | + | } | |
| 30 | + | if got := firstMessageText(startup.Messages()); got != "startup transcript" { | |
| 31 | + | t.Fatalf("startup agent transcript changed to %q", got) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) { | |
| 36 | + | startup := core.NewAgent(nil, "startup-model", "", nil) | |
| 37 | + | if got := liveInteractiveAgent(nil, startup); got != startup { | |
| 38 | + | t.Fatalf("fallback agent = %p, want %p", got, startup) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func firstMessageText(messages []provider.Message) string { | |
| 43 | + | if len(messages) == 0 || len(messages[0].Content) == 0 { | |
| 44 | + | return "" | |
| 45 | + | } | |
| 46 | + | text, _ := messages[0].Content[0].(provider.TextBlock) | |
| 47 | + | return text.Text | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/agent/modes" | |||
| 7 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) { | |||
| 12 | + | startup := core.NewAgent(nil, "startup-model", "", nil) | |||
| 13 | + | startup.SetMessages([]provider.Message{{ | |||
| 14 | + | Role: provider.RoleUser, | |||
| 15 | + | Content: []provider.Content{provider.TextBlock{Text: "startup transcript"}}, | |||
| 16 | + | }}) | |||
| 17 | + | ||||
| 18 | + | replacement := core.NewAgent(nil, "replacement-model", "", nil) | |||
| 19 | + | iv := modes.NewInteractive(modes.InteractiveConfig{Agent: replacement}) | |||
| 20 | + | ||||
| 21 | + | resumed := []provider.Message{{ | |||
| 22 | + | Role: provider.RoleUser, | |||
| 23 | + | Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}}, | |||
| 24 | + | }} | |||
| 25 | + | liveInteractiveAgent(iv, startup).SetMessages(resumed) | |||
| 26 | + | ||||
| 27 | + | if got := firstMessageText(replacement.Messages()); got != "resumed transcript" { | |||
| 28 | + | t.Fatalf("replacement agent transcript = %q, want resumed transcript", got) | |||
| 29 | + | } | |||
| 30 | + | if got := firstMessageText(startup.Messages()); got != "startup transcript" { | |||
| 31 | + | t.Fatalf("startup agent transcript changed to %q", got) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) { | |||
| 36 | + | startup := core.NewAgent(nil, "startup-model", "", nil) | |||
| 37 | + | if got := liveInteractiveAgent(nil, startup); got != startup { | |||
| 38 | + | t.Fatalf("fallback agent = %p, want %p", got, startup) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func firstMessageText(messages []provider.Message) string { | |||
| 43 | + | if len(messages) == 0 || len(messages[0].Content) == 0 { | |||
| 44 | + | return "" | |||
| 45 | + | } | |||
| 46 | + | text, _ := messages[0].Content[0].(provider.TextBlock) | |||
| 47 | + | return text.Text | |||
| 48 | + | } | |||
M packages/agent/modes/interactive.go +20 -7
| @@ -171,10 +171,15 @@ type InteractiveConfig struct { | |||
| 171 | 171 | // picker to only show reachable models. | |
| 172 | 172 | LoggedInProviders func() []string | |
| 173 | 173 | ||
| 174 | - | // ZotHome is the root directory for sessions/, used by /sessions | |
| 175 | - | // and the update-check cache. | |
| 174 | + | // ZotHome is zot's global state directory, used by authentication, | |
| 175 | + | // themes, extensions, and other shared configuration. | |
| 176 | 176 | ZotHome string | |
| 177 | 177 | ||
| 178 | + | // SessionsRoot is the root passed to core session operations. It differs | |
| 179 | + | // from ZotHome for Zotfile agents, whose sessions are isolated by agent | |
| 180 | + | // name. Empty falls back to ZotHome for embedders and tests. | |
| 181 | + | SessionsRoot string | |
| 182 | + | ||
| 178 | 183 | // Version is the binary's current version (from main.version). | |
| 179 | 184 | // Used only for display; the update check itself is done outside | |
| 180 | 185 | // this package to avoid an import cycle. | |
| @@ -1081,6 +1086,14 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 1081 | 1086 | return chat | |
| 1082 | 1087 | } | |
| 1083 | 1088 | ||
| 1089 | + | // sessionsRoot returns the session namespace for this run. | |
| 1090 | + | func (i *Interactive) sessionsRoot() string { | |
| 1091 | + | if i.cfg.SessionsRoot != "" { | |
| 1092 | + | return i.cfg.SessionsRoot | |
| 1093 | + | } | |
| 1094 | + | return i.cfg.ZotHome | |
| 1095 | + | } | |
| 1096 | + | ||
| 1084 | 1097 | // lastCols returns the current terminal width in columns. | |
| 1085 | 1098 | func (i *Interactive) lastCols() int { | |
| 1086 | 1099 | cols, _ := i.cfg.Terminal.Size() | |
| @@ -4220,7 +4233,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4220 | 4233 | case "/settings": | |
| 4221 | 4234 | i.openSettingsDialog() | |
| 4222 | 4235 | case "/sessions": | |
| 4223 | - | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | |
| 4236 | + | i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD) | |
| 4224 | 4237 | case "/jump": | |
| 4225 | 4238 | i.openJumpDialog(parts[1:]) | |
| 4226 | 4239 | case "/btw": | |
| @@ -6476,7 +6489,7 @@ func (i *Interactive) doSessionImport(src string) { | |||
| 6476 | 6489 | i.invalidate() | |
| 6477 | 6490 | return | |
| 6478 | 6491 | } | |
| 6479 | - | newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version) | |
| 6492 | + | newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version) | |
| 6480 | 6493 | if err != nil { | |
| 6481 | 6494 | i.mu.Lock() | |
| 6482 | 6495 | i.statusErr = "import: " + err.Error() | |
| @@ -6605,7 +6618,7 @@ func (i *Interactive) doSessionTree() { | |||
| 6605 | 6618 | if i.cfg.CurrentSessionPath != nil { | |
| 6606 | 6619 | current = i.cfg.CurrentSessionPath() | |
| 6607 | 6620 | } | |
| 6608 | - | if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) { | |
| 6621 | + | if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) { | |
| 6609 | 6622 | i.invalidate() | |
| 6610 | 6623 | return | |
| 6611 | 6624 | } | |
| @@ -6657,7 +6670,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN | |||
| 6657 | 6670 | if role == provider.RoleUser { | |
| 6658 | 6671 | upTo = msgIdx | |
| 6659 | 6672 | } | |
| 6660 | - | newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo) | |
| 6673 | + | newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo) | |
| 6661 | 6674 | if err != nil { | |
| 6662 | 6675 | i.mu.Lock() | |
| 6663 | 6676 | i.statusErr = "tree: " + err.Error() | |
| @@ -6712,7 +6725,7 @@ func (i *Interactive) applyForkSelection(msgIdx int) { | |||
| 6712 | 6725 | // msgIdx is 0-indexed message position; copy msgIdx+1 rows so | |
| 6713 | 6726 | // the selected user message is included. | |
| 6714 | 6727 | upTo := msgIdx + 1 | |
| 6715 | - | newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo) | |
| 6728 | + | newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo) | |
| 6716 | 6729 | if err != nil { | |
| 6717 | 6730 | i.mu.Lock() | |
| 6718 | 6731 | i.statusErr = "fork: " + err.Error() | |
| @@ -171,10 +171,15 @@ type InteractiveConfig struct { | |||||
| 171 | // picker to only show reachable models. | 171 | // picker to only show reachable models. | ||
| 172 | LoggedInProviders func() []string | 172 | LoggedInProviders func() []string | ||
| 173 | 173 | ||||
| 174 | - | // ZotHome is the root directory for sessions/, used by /sessions | 174 | + | // ZotHome is zot's global state directory, used by authentication, |
| 175 | - | // and the update-check cache. | 175 | + | // themes, extensions, and other shared configuration. |
| 176 | ZotHome string | 176 | ZotHome string | ||
| 177 | 177 | ||||
| 178 | + | // SessionsRoot is the root passed to core session operations. It differs | |||
| 179 | + | // from ZotHome for Zotfile agents, whose sessions are isolated by agent | |||
| 180 | + | // name. Empty falls back to ZotHome for embedders and tests. | |||
| 181 | + | SessionsRoot string | |||
| 182 | + | ||||
| 178 | // Version is the binary's current version (from main.version). | 183 | // Version is the binary's current version (from main.version). | ||
| 179 | // Used only for display; the update check itself is done outside | 184 | // Used only for display; the update check itself is done outside | ||
| 180 | // this package to avoid an import cycle. | 185 | // this package to avoid an import cycle. | ||
| @@ -1081,6 +1086,14 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 1081 | return chat | 1086 | return chat | ||
| 1082 | } | 1087 | } | ||
| 1083 | 1088 | ||||
| 1089 | + | // sessionsRoot returns the session namespace for this run. | |||
| 1090 | + | func (i *Interactive) sessionsRoot() string { | |||
| 1091 | + | if i.cfg.SessionsRoot != "" { | |||
| 1092 | + | return i.cfg.SessionsRoot | |||
| 1093 | + | } | |||
| 1094 | + | return i.cfg.ZotHome | |||
| 1095 | + | } | |||
| 1096 | + | ||||
| 1084 | // lastCols returns the current terminal width in columns. | 1097 | // lastCols returns the current terminal width in columns. | ||
| 1085 | func (i *Interactive) lastCols() int { | 1098 | func (i *Interactive) lastCols() int { | ||
| 1086 | cols, _ := i.cfg.Terminal.Size() | 1099 | cols, _ := i.cfg.Terminal.Size() | ||
| @@ -4220,7 +4233,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4220 | case "/settings": | 4233 | case "/settings": | ||
| 4221 | i.openSettingsDialog() | 4234 | i.openSettingsDialog() | ||
| 4222 | case "/sessions": | 4235 | case "/sessions": | ||
| 4223 | - | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | 4236 | + | i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD) |
| 4224 | case "/jump": | 4237 | case "/jump": | ||
| 4225 | i.openJumpDialog(parts[1:]) | 4238 | i.openJumpDialog(parts[1:]) | ||
| 4226 | case "/btw": | 4239 | case "/btw": | ||
| @@ -6476,7 +6489,7 @@ func (i *Interactive) doSessionImport(src string) { | |||||
| 6476 | i.invalidate() | 6489 | i.invalidate() | ||
| 6477 | return | 6490 | return | ||
| 6478 | } | 6491 | } | ||
| 6479 | - | newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version) | 6492 | + | newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version) |
| 6480 | if err != nil { | 6493 | if err != nil { | ||
| 6481 | i.mu.Lock() | 6494 | i.mu.Lock() | ||
| 6482 | i.statusErr = "import: " + err.Error() | 6495 | i.statusErr = "import: " + err.Error() | ||
| @@ -6605,7 +6618,7 @@ func (i *Interactive) doSessionTree() { | |||||
| 6605 | if i.cfg.CurrentSessionPath != nil { | 6618 | if i.cfg.CurrentSessionPath != nil { | ||
| 6606 | current = i.cfg.CurrentSessionPath() | 6619 | current = i.cfg.CurrentSessionPath() | ||
| 6607 | } | 6620 | } | ||
| 6608 | - | if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) { | 6621 | + | if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) { |
| 6609 | i.invalidate() | 6622 | i.invalidate() | ||
| 6610 | return | 6623 | return | ||
| 6611 | } | 6624 | } | ||
| @@ -6657,7 +6670,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN | |||||
| 6657 | if role == provider.RoleUser { | 6670 | if role == provider.RoleUser { | ||
| 6658 | upTo = msgIdx | 6671 | upTo = msgIdx | ||
| 6659 | } | 6672 | } | ||
| 6660 | - | newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo) | 6673 | + | newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo) |
| 6661 | if err != nil { | 6674 | if err != nil { | ||
| 6662 | i.mu.Lock() | 6675 | i.mu.Lock() | ||
| 6663 | i.statusErr = "tree: " + err.Error() | 6676 | i.statusErr = "tree: " + err.Error() | ||
| @@ -6712,7 +6725,7 @@ func (i *Interactive) applyForkSelection(msgIdx int) { | |||||
| 6712 | // msgIdx is 0-indexed message position; copy msgIdx+1 rows so | 6725 | // msgIdx is 0-indexed message position; copy msgIdx+1 rows so | ||
| 6713 | // the selected user message is included. | 6726 | // the selected user message is included. | ||
| 6714 | upTo := msgIdx + 1 | 6727 | upTo := msgIdx + 1 | ||
| 6715 | - | newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo) | 6728 | + | newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo) |
| 6716 | if err != nil { | 6729 | if err != nil { | ||
| 6717 | i.mu.Lock() | 6730 | i.mu.Lock() | ||
| 6718 | i.statusErr = "fork: " + err.Error() | 6731 | i.statusErr = "fork: " + err.Error() | ||
A packages/agent/modes/session_agent_root_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/core" | |
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) { | |
| 12 | + | zotHome := t.TempDir() | |
| 13 | + | agentRoot := t.TempDir() | |
| 14 | + | cwd := t.TempDir() | |
| 15 | + | ||
| 16 | + | session, err := core.NewSession(agentRoot, cwd, "test", "test-model", "test-version") | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | if err := session.AppendMessage(provider.Message{ | |
| 21 | + | Role: provider.RoleUser, | |
| 22 | + | Content: []provider.Content{provider.TextBlock{Text: "agent session"}}, | |
| 23 | + | }); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | if err := session.Close(); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | i := NewInteractive(InteractiveConfig{ | |
| 31 | + | ZotHome: zotHome, | |
| 32 | + | SessionsRoot: agentRoot, | |
| 33 | + | CWD: cwd, | |
| 34 | + | }) | |
| 35 | + | i.runSlash(context.Background(), "/sessions") | |
| 36 | + | ||
| 37 | + | if got := len(i.sessionDialog.sessions); got != 1 { | |
| 38 | + | t.Fatalf("session picker entries = %d, want 1 from agent session root", got) | |
| 39 | + | } | |
| 40 | + | if got := i.sessionDialog.sessions[0].Path; got != session.Path { | |
| 41 | + | t.Fatalf("session picker path = %q, want %q", got, session.Path) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestSessionsRootDefaultsToZotHome(t *testing.T) { | |
| 46 | + | i := &Interactive{cfg: InteractiveConfig{ZotHome: "/zot/home"}} | |
| 47 | + | if got := i.sessionsRoot(); got != "/zot/home" { | |
| 48 | + | t.Fatalf("sessions root = %q, want ZotHome fallback", got) | |
| 49 | + | } | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) { | |||
| 12 | + | zotHome := t.TempDir() | |||
| 13 | + | agentRoot := t.TempDir() | |||
| 14 | + | cwd := t.TempDir() | |||
| 15 | + | ||||
| 16 | + | session, err := core.NewSession(agentRoot, cwd, "test", "test-model", "test-version") | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | if err := session.AppendMessage(provider.Message{ | |||
| 21 | + | Role: provider.RoleUser, | |||
| 22 | + | Content: []provider.Content{provider.TextBlock{Text: "agent session"}}, | |||
| 23 | + | }); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | if err := session.Close(); err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | i := NewInteractive(InteractiveConfig{ | |||
| 31 | + | ZotHome: zotHome, | |||
| 32 | + | SessionsRoot: agentRoot, | |||
| 33 | + | CWD: cwd, | |||
| 34 | + | }) | |||
| 35 | + | i.runSlash(context.Background(), "/sessions") | |||
| 36 | + | ||||
| 37 | + | if got := len(i.sessionDialog.sessions); got != 1 { | |||
| 38 | + | t.Fatalf("session picker entries = %d, want 1 from agent session root", got) | |||
| 39 | + | } | |||
| 40 | + | if got := i.sessionDialog.sessions[0].Path; got != session.Path { | |||
| 41 | + | t.Fatalf("session picker path = %q, want %q", got, session.Path) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestSessionsRootDefaultsToZotHome(t *testing.T) { | |||
| 46 | + | i := &Interactive{cfg: InteractiveConfig{ZotHome: "/zot/home"}} | |||
| 47 | + | if got := i.sessionsRoot(); got != "/zot/home" { | |||
| 48 | + | t.Fatalf("sessions root = %q, want ZotHome fallback", got) | |||
| 49 | + | } | |||
| 50 | + | } | |||