Add command to start a new session
- commit
- 6f6753ccfc15aeb4a54fb500a55b6f72db55fd02
- author
- patriceckhart · · 2026-09-24 05:48 UTC
- parent
- 7d78d75
- signature
- unsigned
- refs
- tag: v0.3.90
9 files · +332 -20
M README.md +3 -2
| @@ -351,6 +351,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 351 | 351 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | |
| 352 | 352 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | |
| 353 | 353 | | `/sessions` | Resume a previous session for this directory. | | |
| 354 | + | | `/new` | Persist the current conversation and start a fresh session in the same directory. Unavailable with `--no-session`. | | |
| 354 | 355 | | `/session` | Five ops on the current session: inspect its `timeline`, `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message, or view its branch `tree`. Opens a picker without an argument; direct forms include `/session timeline`, `/session export [path]`, `/session import <path>`, `/session fork`, and `/session tree`. Default export destination is `~/Downloads`. | | |
| 355 | 356 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 356 | 357 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | |
| @@ -511,7 +512,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need | |||
| 511 | 512 | ||
| 512 | 513 | ## Sessions | |
| 513 | 514 | ||
| 514 | - | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | |
| 515 | + | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. In interactive mode, `/new` persists the current conversation and starts an empty session while preserving the working directory, model, tools, extensions, and runtime settings. It does not make a model call, and the previous conversation remains available through `/sessions`. `/new` is hidden from autocomplete and `/help` with `--no-session`, and invoking it directly reports that sessions are disabled. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | |
| 515 | 516 | ||
| 516 | 517 | Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number and human-readable total size of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, so review the selection and ensure remote filesystems are mounted before deleting missing-directory groups. | |
| 517 | 518 | ||
| @@ -1118,7 +1119,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q | |||
| 1118 | 1119 | ||
| 1119 | 1120 | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | |
| 1120 | 1121 | ||
| 1121 | - | Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | |
| 1122 | + | Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/new`, `/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | |
| 1122 | 1123 | ||
| 1123 | 1124 | ||
| 1124 | 1125 | ## Keys (interactive mode) | |
| @@ -351,6 +351,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 351 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | 351 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | ||
| 352 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | 352 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | ||
| 353 | | `/sessions` | Resume a previous session for this directory. | | 353 | | `/sessions` | Resume a previous session for this directory. | | ||
| 354 | + | | `/new` | Persist the current conversation and start a fresh session in the same directory. Unavailable with `--no-session`. | | |||
| 354 | | `/session` | Five ops on the current session: inspect its `timeline`, `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message, or view its branch `tree`. Opens a picker without an argument; direct forms include `/session timeline`, `/session export [path]`, `/session import <path>`, `/session fork`, and `/session tree`. Default export destination is `~/Downloads`. | | 355 | | `/session` | Five ops on the current session: inspect its `timeline`, `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message, or view its branch `tree`. Opens a picker without an argument; direct forms include `/session timeline`, `/session export [path]`, `/session import <path>`, `/session fork`, and `/session tree`. Default export destination is `~/Downloads`. | | ||
| 355 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | 356 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | ||
| 356 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | 357 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | ||
| @@ -511,7 +512,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need | |||||
| 511 | 512 | ||||
| 512 | ## Sessions | 513 | ## Sessions | ||
| 513 | 514 | ||||
| 514 | - | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | 515 | + | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. In interactive mode, `/new` persists the current conversation and starts an empty session while preserving the working directory, model, tools, extensions, and runtime settings. It does not make a model call, and the previous conversation remains available through `/sessions`. `/new` is hidden from autocomplete and `/help` with `--no-session`, and invoking it directly reports that sessions are disabled. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. |
| 515 | 516 | ||||
| 516 | Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number and human-readable total size of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, so review the selection and ensure remote filesystems are mounted before deleting missing-directory groups. | 517 | Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number and human-readable total size of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, so review the selection and ensure remote filesystems are mounted before deleting missing-directory groups. | ||
| 517 | 518 | ||||
| @@ -1118,7 +1119,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q | |||||
| 1118 | 1119 | ||||
| 1119 | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | 1120 | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | ||
| 1120 | 1121 | ||||
| 1121 | - | Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | 1122 | + | Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/new`, `/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. |
| 1122 | 1123 | ||||
| 1123 | 1124 | ||||
| 1124 | ## Keys (interactive mode) | 1125 | ## Keys (interactive mode) | ||
M packages/agent/cli.go +58 -0
| @@ -1105,6 +1105,40 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1105 | 1105 | return nil | |
| 1106 | 1106 | } | |
| 1107 | 1107 | ||
| 1108 | + | var newSession func(providerName, model string) error | |
| 1109 | + | if !args.NoSess { | |
| 1110 | + | newSession = func(providerName, model string) error { | |
| 1111 | + | currentAg := liveInteractiveAgent(iv, ag) | |
| 1112 | + | if currentAg == nil { | |
| 1113 | + | return fmt.Errorf("no agent running; log in first") | |
| 1114 | + | } | |
| 1115 | + | ||
| 1116 | + | persistMu.Lock() | |
| 1117 | + | newSess, err := rotateInteractiveSession( | |
| 1118 | + | agentSessionsRoot(ZotHome(), args), r.CWD, providerName, model, version, | |
| 1119 | + | currentAg, sess, sessBaselineMsgs, | |
| 1120 | + | ) | |
| 1121 | + | if err == nil { | |
| 1122 | + | sess = newSess | |
| 1123 | + | sessBaselineMsgs = 0 | |
| 1124 | + | } | |
| 1125 | + | persistMu.Unlock() | |
| 1126 | + | if err != nil { | |
| 1127 | + | return err | |
| 1128 | + | } | |
| 1129 | + | ||
| 1130 | + | active := r | |
| 1131 | + | active.Provider = providerName | |
| 1132 | + | active.Model = model | |
| 1133 | + | setZotSessionEnvironment(active, newSess) | |
| 1134 | + | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | |
| 1135 | + | if swarmMgr != nil { | |
| 1136 | + | swarmMgr.SetActiveSession(newSess.ID) | |
| 1137 | + | } | |
| 1138 | + | return nil | |
| 1139 | + | } | |
| 1140 | + | } | |
| 1141 | + | ||
| 1108 | 1142 | // changeCWD switches the running session to a new working directory. | |
| 1109 | 1143 | // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden | |
| 1110 | 1144 | // /cd slash command (which itself is only fired by the workspaces | |
| @@ -1424,6 +1458,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1424 | 1458 | } | |
| 1425 | 1459 | return out | |
| 1426 | 1460 | }, | |
| 1461 | + | NewSession: newSession, | |
| 1427 | 1462 | LoadSession: loadSession, | |
| 1428 | 1463 | ChangeCWD: changeCWD, | |
| 1429 | 1464 | CurrentSessionPath: func() string { | |
| @@ -1688,6 +1723,29 @@ func pickSession(root, cwd string) (string, error) { | |||
| 1688 | 1723 | return files[n-1], nil | |
| 1689 | 1724 | } | |
| 1690 | 1725 | ||
| 1726 | + | // rotateInteractiveSession creates the replacement before mutating the live | |
| 1727 | + | // agent, so a creation failure leaves the current conversation usable. | |
| 1728 | + | // Callers must hold the interactive persistence mutex. | |
| 1729 | + | func rotateInteractiveSession(root, cwd, providerName, model, version string, ag *core.Agent, old *core.Session, baseline int) (*core.Session, error) { | |
| 1730 | + | if ag == nil { | |
| 1731 | + | return nil, fmt.Errorf("no agent running") | |
| 1732 | + | } | |
| 1733 | + | fresh, err := core.NewSession(root, cwd, providerName, model, version) | |
| 1734 | + | if err != nil { | |
| 1735 | + | return nil, err | |
| 1736 | + | } | |
| 1737 | + | if old != nil { | |
| 1738 | + | writeNewTranscriptLocked(ag, old, baseline) | |
| 1739 | + | _ = old.Close() | |
| 1740 | + | } | |
| 1741 | + | ag.SetMessages(nil) | |
| 1742 | + | ag.DrainQueuedMessages() | |
| 1743 | + | ag.SeedCost(provider.Usage{}) | |
| 1744 | + | ag.SeedLastTurnUsage(provider.Usage{}) | |
| 1745 | + | bindAgentSession(ag, fresh) | |
| 1746 | + | return fresh, nil | |
| 1747 | + | } | |
| 1748 | + | ||
| 1691 | 1749 | // WriteNewTranscript appends only messages after index `from` from the | |
| 1692 | 1750 | // agent's transcript to the session. Used by callers that don't hold | |
| 1693 | 1751 | // the persistMu (non-interactive print/json modes which run a single | |
| @@ -1105,6 +1105,40 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1105 | return nil | 1105 | return nil | ||
| 1106 | } | 1106 | } | ||
| 1107 | 1107 | ||||
| 1108 | + | var newSession func(providerName, model string) error | |||
| 1109 | + | if !args.NoSess { | |||
| 1110 | + | newSession = func(providerName, model string) error { | |||
| 1111 | + | currentAg := liveInteractiveAgent(iv, ag) | |||
| 1112 | + | if currentAg == nil { | |||
| 1113 | + | return fmt.Errorf("no agent running; log in first") | |||
| 1114 | + | } | |||
| 1115 | + | ||||
| 1116 | + | persistMu.Lock() | |||
| 1117 | + | newSess, err := rotateInteractiveSession( | |||
| 1118 | + | agentSessionsRoot(ZotHome(), args), r.CWD, providerName, model, version, | |||
| 1119 | + | currentAg, sess, sessBaselineMsgs, | |||
| 1120 | + | ) | |||
| 1121 | + | if err == nil { | |||
| 1122 | + | sess = newSess | |||
| 1123 | + | sessBaselineMsgs = 0 | |||
| 1124 | + | } | |||
| 1125 | + | persistMu.Unlock() | |||
| 1126 | + | if err != nil { | |||
| 1127 | + | return err | |||
| 1128 | + | } | |||
| 1129 | + | ||||
| 1130 | + | active := r | |||
| 1131 | + | active.Provider = providerName | |||
| 1132 | + | active.Model = model | |||
| 1133 | + | setZotSessionEnvironment(active, newSess) | |||
| 1134 | + | startExtensionSession(extMgr, currentAg, r.CWD, "session_switch") | |||
| 1135 | + | if swarmMgr != nil { | |||
| 1136 | + | swarmMgr.SetActiveSession(newSess.ID) | |||
| 1137 | + | } | |||
| 1138 | + | return nil | |||
| 1139 | + | } | |||
| 1140 | + | } | |||
| 1141 | + | ||||
| 1108 | // changeCWD switches the running session to a new working directory. | 1142 | // changeCWD switches the running session to a new working directory. | ||
| 1109 | // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden | 1143 | // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden | ||
| 1110 | // /cd slash command (which itself is only fired by the workspaces | 1144 | // /cd slash command (which itself is only fired by the workspaces | ||
| @@ -1424,6 +1458,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1424 | } | 1458 | } | ||
| 1425 | return out | 1459 | return out | ||
| 1426 | }, | 1460 | }, | ||
| 1461 | + | NewSession: newSession, | |||
| 1427 | LoadSession: loadSession, | 1462 | LoadSession: loadSession, | ||
| 1428 | ChangeCWD: changeCWD, | 1463 | ChangeCWD: changeCWD, | ||
| 1429 | CurrentSessionPath: func() string { | 1464 | CurrentSessionPath: func() string { | ||
| @@ -1688,6 +1723,29 @@ func pickSession(root, cwd string) (string, error) { | |||||
| 1688 | return files[n-1], nil | 1723 | return files[n-1], nil | ||
| 1689 | } | 1724 | } | ||
| 1690 | 1725 | ||||
| 1726 | + | // rotateInteractiveSession creates the replacement before mutating the live | |||
| 1727 | + | // agent, so a creation failure leaves the current conversation usable. | |||
| 1728 | + | // Callers must hold the interactive persistence mutex. | |||
| 1729 | + | func rotateInteractiveSession(root, cwd, providerName, model, version string, ag *core.Agent, old *core.Session, baseline int) (*core.Session, error) { | |||
| 1730 | + | if ag == nil { | |||
| 1731 | + | return nil, fmt.Errorf("no agent running") | |||
| 1732 | + | } | |||
| 1733 | + | fresh, err := core.NewSession(root, cwd, providerName, model, version) | |||
| 1734 | + | if err != nil { | |||
| 1735 | + | return nil, err | |||
| 1736 | + | } | |||
| 1737 | + | if old != nil { | |||
| 1738 | + | writeNewTranscriptLocked(ag, old, baseline) | |||
| 1739 | + | _ = old.Close() | |||
| 1740 | + | } | |||
| 1741 | + | ag.SetMessages(nil) | |||
| 1742 | + | ag.DrainQueuedMessages() | |||
| 1743 | + | ag.SeedCost(provider.Usage{}) | |||
| 1744 | + | ag.SeedLastTurnUsage(provider.Usage{}) | |||
| 1745 | + | bindAgentSession(ag, fresh) | |||
| 1746 | + | return fresh, nil | |||
| 1747 | + | } | |||
| 1748 | + | ||||
| 1691 | // WriteNewTranscript appends only messages after index `from` from the | 1749 | // WriteNewTranscript appends only messages after index `from` from the | ||
| 1692 | // agent's transcript to the session. Used by callers that don't hold | 1750 | // agent's transcript to the session. Used by callers that don't hold | ||
| 1693 | // the persistMu (non-interactive print/json modes which run a single | 1751 | // the persistMu (non-interactive print/json modes which run a single | ||
M packages/agent/cli_session_test.go +68 -0
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/patriceckhart/zot/packages/agent/modes" | |
| @@ -48,6 +50,72 @@ func TestBindAgentSession(t *testing.T) { | |||
| 48 | 50 | } | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 53 | + | func TestRotateInteractiveSessionPreservesOldTranscriptAndResetsAgent(t *testing.T) { | |
| 54 | + | root := t.TempDir() | |
| 55 | + | cwd := t.TempDir() | |
| 56 | + | old, err := core.NewSession(root, cwd, "test-provider", "old-model", "test") | |
| 57 | + | if err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | ag := core.NewAgent(nil, "new-model", "", nil) | |
| 61 | + | ag.SetMessages([]provider.Message{{ | |
| 62 | + | Role: provider.RoleUser, | |
| 63 | + | Content: []provider.Content{provider.TextBlock{Text: "keep this conversation"}}, | |
| 64 | + | }}) | |
| 65 | + | ag.SeedCost(provider.Usage{InputTokens: 12, CostUSD: 0.25}) | |
| 66 | + | ag.SeedLastTurnUsage(provider.Usage{InputTokens: 12}) | |
| 67 | + | ag.QueueMessage("drop queued prompt") | |
| 68 | + | ||
| 69 | + | fresh, err := rotateInteractiveSession(root, cwd, "test-provider", "new-model", "test", ag, old, 0) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | defer fresh.Close() | |
| 74 | + | if fresh.ID == old.ID || fresh.Path == old.Path { | |
| 75 | + | t.Fatalf("session was not rotated: old=%s new=%s", old.Path, fresh.Path) | |
| 76 | + | } | |
| 77 | + | if ag.SessionID != fresh.ID { | |
| 78 | + | t.Fatalf("agent session ID = %q, want %q", ag.SessionID, fresh.ID) | |
| 79 | + | } | |
| 80 | + | if len(ag.Messages()) != 0 { | |
| 81 | + | t.Fatalf("new transcript is not empty: %+v", ag.Messages()) | |
| 82 | + | } | |
| 83 | + | if got := ag.Cost(); got != (provider.Usage{}) { | |
| 84 | + | t.Fatalf("new session cost = %+v, want zero", got) | |
| 85 | + | } | |
| 86 | + | if got := ag.LastTurnUsage(); got != (provider.Usage{}) { | |
| 87 | + | t.Fatalf("new session last usage = %+v, want zero", got) | |
| 88 | + | } | |
| 89 | + | if got := ag.QueuedMessageCount(); got != 0 { | |
| 90 | + | t.Fatalf("queued messages = %d, want zero", got) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | persisted, messages, err := core.OpenSession(old.Path) | |
| 94 | + | if err != nil { | |
| 95 | + | t.Fatalf("open previous session: %v", err) | |
| 96 | + | } | |
| 97 | + | defer persisted.Close() | |
| 98 | + | if got := firstMessageText(messages); got != "keep this conversation" { | |
| 99 | + | t.Fatalf("previous transcript = %q", got) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestRotateInteractiveSessionCreationFailureLeavesAgentUntouched(t *testing.T) { | |
| 104 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 105 | + | ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "still active"}}}}) | |
| 106 | + | blockedRoot := filepath.Join(t.TempDir(), "file") | |
| 107 | + | if err := os.WriteFile(blockedRoot, []byte("not a directory"), 0o600); err != nil { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | if _, err := rotateInteractiveSession(blockedRoot, t.TempDir(), "test", "model", "test", ag, nil, 0); err == nil { | |
| 112 | + | t.Fatal("rotateInteractiveSession returned nil error for invalid root") | |
| 113 | + | } | |
| 114 | + | if got := firstMessageText(ag.Messages()); got != "still active" { | |
| 115 | + | t.Fatalf("agent transcript changed to %q after failed rotation", got) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 51 | 119 | func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) { | |
| 52 | 120 | startup := core.NewAgent(nil, "startup-model", "", nil) | |
| 53 | 121 | if got := liveInteractiveAgent(nil, startup); got != startup { | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/patriceckhart/zot/packages/agent/modes" | 8 | "github.com/patriceckhart/zot/packages/agent/modes" | ||
| @@ -48,6 +50,72 @@ func TestBindAgentSession(t *testing.T) { | |||||
| 48 | } | 50 | } | ||
| 49 | } | 51 | } | ||
| 50 | 52 | ||||
| 53 | + | func TestRotateInteractiveSessionPreservesOldTranscriptAndResetsAgent(t *testing.T) { | |||
| 54 | + | root := t.TempDir() | |||
| 55 | + | cwd := t.TempDir() | |||
| 56 | + | old, err := core.NewSession(root, cwd, "test-provider", "old-model", "test") | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | ag := core.NewAgent(nil, "new-model", "", nil) | |||
| 61 | + | ag.SetMessages([]provider.Message{{ | |||
| 62 | + | Role: provider.RoleUser, | |||
| 63 | + | Content: []provider.Content{provider.TextBlock{Text: "keep this conversation"}}, | |||
| 64 | + | }}) | |||
| 65 | + | ag.SeedCost(provider.Usage{InputTokens: 12, CostUSD: 0.25}) | |||
| 66 | + | ag.SeedLastTurnUsage(provider.Usage{InputTokens: 12}) | |||
| 67 | + | ag.QueueMessage("drop queued prompt") | |||
| 68 | + | ||||
| 69 | + | fresh, err := rotateInteractiveSession(root, cwd, "test-provider", "new-model", "test", ag, old, 0) | |||
| 70 | + | if err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | defer fresh.Close() | |||
| 74 | + | if fresh.ID == old.ID || fresh.Path == old.Path { | |||
| 75 | + | t.Fatalf("session was not rotated: old=%s new=%s", old.Path, fresh.Path) | |||
| 76 | + | } | |||
| 77 | + | if ag.SessionID != fresh.ID { | |||
| 78 | + | t.Fatalf("agent session ID = %q, want %q", ag.SessionID, fresh.ID) | |||
| 79 | + | } | |||
| 80 | + | if len(ag.Messages()) != 0 { | |||
| 81 | + | t.Fatalf("new transcript is not empty: %+v", ag.Messages()) | |||
| 82 | + | } | |||
| 83 | + | if got := ag.Cost(); got != (provider.Usage{}) { | |||
| 84 | + | t.Fatalf("new session cost = %+v, want zero", got) | |||
| 85 | + | } | |||
| 86 | + | if got := ag.LastTurnUsage(); got != (provider.Usage{}) { | |||
| 87 | + | t.Fatalf("new session last usage = %+v, want zero", got) | |||
| 88 | + | } | |||
| 89 | + | if got := ag.QueuedMessageCount(); got != 0 { | |||
| 90 | + | t.Fatalf("queued messages = %d, want zero", got) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | persisted, messages, err := core.OpenSession(old.Path) | |||
| 94 | + | if err != nil { | |||
| 95 | + | t.Fatalf("open previous session: %v", err) | |||
| 96 | + | } | |||
| 97 | + | defer persisted.Close() | |||
| 98 | + | if got := firstMessageText(messages); got != "keep this conversation" { | |||
| 99 | + | t.Fatalf("previous transcript = %q", got) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestRotateInteractiveSessionCreationFailureLeavesAgentUntouched(t *testing.T) { | |||
| 104 | + | ag := core.NewAgent(nil, "model", "", nil) | |||
| 105 | + | ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "still active"}}}}) | |||
| 106 | + | blockedRoot := filepath.Join(t.TempDir(), "file") | |||
| 107 | + | if err := os.WriteFile(blockedRoot, []byte("not a directory"), 0o600); err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | if _, err := rotateInteractiveSession(blockedRoot, t.TempDir(), "test", "model", "test", ag, nil, 0); err == nil { | |||
| 112 | + | t.Fatal("rotateInteractiveSession returned nil error for invalid root") | |||
| 113 | + | } | |||
| 114 | + | if got := firstMessageText(ag.Messages()); got != "still active" { | |||
| 115 | + | t.Fatalf("agent transcript changed to %q after failed rotation", got) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 51 | func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) { | 119 | func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) { | ||
| 52 | startup := core.NewAgent(nil, "startup-model", "", nil) | 120 | startup := core.NewAgent(nil, "startup-model", "", nil) | ||
| 53 | if got := liveInteractiveAgent(nil, startup); got != startup { | 121 | if got := liveInteractiveAgent(nil, startup); got != startup { | ||
M packages/agent/modes/help.go +3 -3
| @@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{ | |||
| 36 | 36 | // section it lives in. The width is computed from the longest label | |
| 37 | 37 | // across BOTH lists, with a minimum of 14 cells so changes to either | |
| 38 | 38 | // list don't compress the column visually. | |
| 39 | - | func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []keymapBinding) []string { | |
| 39 | + | func renderHelpBlock(th tui.Theme, width int, llamaConfigured, sessionsEnabled bool, keymap []keymapBinding) []string { | |
| 40 | 40 | if width < 20 { | |
| 41 | 41 | width = 20 | |
| 42 | 42 | } | |
| @@ -50,7 +50,7 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []key | |||
| 50 | 50 | // already, leaving its description mis-aligned). | |
| 51 | 51 | labelWidth := 14 | |
| 52 | 52 | for _, c := range slashCatalog { | |
| 53 | - | if c.Name == "/llama" && !llamaConfigured { | |
| 53 | + | if (c.Name == "/llama" && !llamaConfigured) || (c.Name == "/new" && !sessionsEnabled) { | |
| 54 | 54 | continue | |
| 55 | 55 | } | |
| 56 | 56 | if n := runewidth.StringWidth(c.Name); n > labelWidth { | |
| @@ -82,7 +82,7 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []key | |||
| 82 | 82 | // commands section | |
| 83 | 83 | out = append(out, tui.Bold("slash commands:")) | |
| 84 | 84 | for _, c := range slashCatalog { | |
| 85 | - | if c.Name == "/llama" && !llamaConfigured { | |
| 85 | + | if (c.Name == "/llama" && !llamaConfigured) || (c.Name == "/new" && !sessionsEnabled) { | |
| 86 | 86 | continue | |
| 87 | 87 | } | |
| 88 | 88 | out = append(out, fmt.Sprintf(" %s %s", | |
| @@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{ | |||||
| 36 | // section it lives in. The width is computed from the longest label | 36 | // section it lives in. The width is computed from the longest label | ||
| 37 | // across BOTH lists, with a minimum of 14 cells so changes to either | 37 | // across BOTH lists, with a minimum of 14 cells so changes to either | ||
| 38 | // list don't compress the column visually. | 38 | // list don't compress the column visually. | ||
| 39 | - | func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []keymapBinding) []string { | 39 | + | func renderHelpBlock(th tui.Theme, width int, llamaConfigured, sessionsEnabled bool, keymap []keymapBinding) []string { |
| 40 | if width < 20 { | 40 | if width < 20 { | ||
| 41 | width = 20 | 41 | width = 20 | ||
| 42 | } | 42 | } | ||
| @@ -50,7 +50,7 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []key | |||||
| 50 | // already, leaving its description mis-aligned). | 50 | // already, leaving its description mis-aligned). | ||
| 51 | labelWidth := 14 | 51 | labelWidth := 14 | ||
| 52 | for _, c := range slashCatalog { | 52 | for _, c := range slashCatalog { | ||
| 53 | - | if c.Name == "/llama" && !llamaConfigured { | 53 | + | if (c.Name == "/llama" && !llamaConfigured) || (c.Name == "/new" && !sessionsEnabled) { |
| 54 | continue | 54 | continue | ||
| 55 | } | 55 | } | ||
| 56 | if n := runewidth.StringWidth(c.Name); n > labelWidth { | 56 | if n := runewidth.StringWidth(c.Name); n > labelWidth { | ||
| @@ -82,7 +82,7 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []key | |||||
| 82 | // commands section | 82 | // commands section | ||
| 83 | out = append(out, tui.Bold("slash commands:")) | 83 | out = append(out, tui.Bold("slash commands:")) | ||
| 84 | for _, c := range slashCatalog { | 84 | for _, c := range slashCatalog { | ||
| 85 | - | if c.Name == "/llama" && !llamaConfigured { | 85 | + | if (c.Name == "/llama" && !llamaConfigured) || (c.Name == "/new" && !sessionsEnabled) { |
| 86 | continue | 86 | continue | ||
| 87 | } | 87 | } | ||
| 88 | out = append(out, fmt.Sprintf(" %s %s", | 88 | out = append(out, fmt.Sprintf(" %s %s", | ||
M packages/agent/modes/help_test.go +14 -3
| @@ -8,16 +8,27 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | |
| 11 | - | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, nil), "\n") | |
| 11 | + | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, nil), "\n") | |
| 12 | 12 | if strings.Contains(without, "/llama") { | |
| 13 | 13 | t.Fatalf("help exposed /llama without login: %q", without) | |
| 14 | 14 | } | |
| 15 | - | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, nil), "\n") | |
| 15 | + | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, true, nil), "\n") | |
| 16 | 16 | if !strings.Contains(with, "/llama") { | |
| 17 | 17 | t.Fatalf("help omitted /llama with login: %q", with) | |
| 18 | 18 | } | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | + | func TestHelpHidesNewWhenSessionsAreDisabled(t *testing.T) { | |
| 22 | + | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, false, nil), "\n") | |
| 23 | + | if strings.Contains(without, "/new") { | |
| 24 | + | t.Fatalf("help exposed /new with sessions disabled: %q", without) | |
| 25 | + | } | |
| 26 | + | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, nil), "\n") | |
| 27 | + | if !strings.Contains(with, "/new") { | |
| 28 | + | t.Fatalf("help omitted /new with sessions enabled: %q", with) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 21 | 32 | func TestHelpShowsConfiguredCustomKeys(t *testing.T) { | |
| 22 | 33 | bindings, issues := compileKeymap(map[string]string{ | |
| 23 | 34 | "Ctrl+S": "/skill:review", | |
| @@ -26,7 +37,7 @@ func TestHelpShowsConfiguredCustomKeys(t *testing.T) { | |||
| 26 | 37 | if len(issues) != 1 { | |
| 27 | 38 | t.Fatalf("compileKeymap issues = %v, want one for bogus", issues) | |
| 28 | 39 | } | |
| 29 | - | help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, bindings), "\n") | |
| 40 | + | help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, bindings), "\n") | |
| 30 | 41 | if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") { | |
| 31 | 42 | t.Fatalf("help omitted configured custom key: %q", help) | |
| 32 | 43 | } | |
| @@ -8,16 +8,27 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | ||
| 11 | - | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, nil), "\n") | 11 | + | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, nil), "\n") |
| 12 | if strings.Contains(without, "/llama") { | 12 | if strings.Contains(without, "/llama") { | ||
| 13 | t.Fatalf("help exposed /llama without login: %q", without) | 13 | t.Fatalf("help exposed /llama without login: %q", without) | ||
| 14 | } | 14 | } | ||
| 15 | - | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, nil), "\n") | 15 | + | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, true, nil), "\n") |
| 16 | if !strings.Contains(with, "/llama") { | 16 | if !strings.Contains(with, "/llama") { | ||
| 17 | t.Fatalf("help omitted /llama with login: %q", with) | 17 | t.Fatalf("help omitted /llama with login: %q", with) | ||
| 18 | } | 18 | } | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | + | func TestHelpHidesNewWhenSessionsAreDisabled(t *testing.T) { | |||
| 22 | + | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, false, nil), "\n") | |||
| 23 | + | if strings.Contains(without, "/new") { | |||
| 24 | + | t.Fatalf("help exposed /new with sessions disabled: %q", without) | |||
| 25 | + | } | |||
| 26 | + | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, nil), "\n") | |||
| 27 | + | if !strings.Contains(with, "/new") { | |||
| 28 | + | t.Fatalf("help omitted /new with sessions enabled: %q", with) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 21 | func TestHelpShowsConfiguredCustomKeys(t *testing.T) { | 32 | func TestHelpShowsConfiguredCustomKeys(t *testing.T) { | ||
| 22 | bindings, issues := compileKeymap(map[string]string{ | 33 | bindings, issues := compileKeymap(map[string]string{ | ||
| 23 | "Ctrl+S": "/skill:review", | 34 | "Ctrl+S": "/skill:review", | ||
| @@ -26,7 +37,7 @@ func TestHelpShowsConfiguredCustomKeys(t *testing.T) { | |||||
| 26 | if len(issues) != 1 { | 37 | if len(issues) != 1 { | ||
| 27 | t.Fatalf("compileKeymap issues = %v, want one for bogus", issues) | 38 | t.Fatalf("compileKeymap issues = %v, want one for bogus", issues) | ||
| 28 | } | 39 | } | ||
| 29 | - | help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, bindings), "\n") | 40 | + | help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, bindings), "\n") |
| 30 | if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") { | 41 | if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") { | ||
| 31 | t.Fatalf("help omitted configured custom key: %q", help) | 42 | t.Fatalf("help omitted configured custom key: %q", help) | ||
| 32 | } | 43 | } | ||
M packages/agent/modes/interactive.go +70 -10
| @@ -253,6 +253,11 @@ type InteractiveConfig struct { | |||
| 253 | 253 | // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail. | |
| 254 | 254 | Sandbox *tools.Sandbox | |
| 255 | 255 | ||
| 256 | + | // NewSession persists and closes the current session, then creates and | |
| 257 | + | // binds an empty session in the same working directory. It is nil when | |
| 258 | + | // persistence is disabled, including runs started with --no-session. | |
| 259 | + | NewSession func(providerName, model string) error | |
| 260 | + | ||
| 256 | 261 | // LoadSession swaps the current session for the one at path. The | |
| 257 | 262 | // callback returns the new agent message slice so the TUI can invalidate. | |
| 258 | 263 | LoadSession func(path string) error | |
| @@ -729,6 +734,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 729 | 734 | i.reloadErrors = append(i.reloadErrors, keymapIssues...) | |
| 730 | 735 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 731 | 736 | i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest) | |
| 737 | + | i.suggest.SetSessionsEnabled(cfg.NewSession != nil) | |
| 732 | 738 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| 733 | 739 | if cfg.LlamaCPPConfig != nil { | |
| 734 | 740 | baseURL, _, err := cfg.LlamaCPPConfig() | |
| @@ -1449,8 +1455,8 @@ func (i *Interactive) redraw() { | |||
| 1449 | 1455 | i.suggest.SetSkills(list) | |
| 1450 | 1456 | } | |
| 1451 | 1457 | // Slash popup renders even while the agent is busy so the user | |
| 1452 | - | // can queue a destructive command (/clear, /compact, /logout, | |
| 1453 | - | // /model) or a read-only one (/help, /jump, /sessions, etc.) | |
| 1458 | + | // can queue a destructive command (/new, /clear, /compact, | |
| 1459 | + | // /logout, /model) or a read-only one (/help, /jump, /sessions, etc.) | |
| 1454 | 1460 | // without waiting for the current turn to finish. The dispatcher | |
| 1455 | 1461 | // in runSlash already handles the busy case per-command: safe | |
| 1456 | 1462 | // ones run immediately, destructive ones cancel the turn first. | |
| @@ -2537,7 +2543,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2537 | 2543 | if !keymapReservedKey(k) { | |
| 2538 | 2544 | if command := lookupKeymap(i.keymap, k); command != "" { | |
| 2539 | 2545 | parts := strings.Fields(command) | |
| 2540 | - | if len(parts) > 0 && slashCancelsTurn(parts[0]) { | |
| 2546 | + | if len(parts) > 0 && i.slashCancelsActiveTurn(parts[0]) { | |
| 2541 | 2547 | i.cancelAndWaitForIdle() | |
| 2542 | 2548 | } | |
| 2543 | 2549 | i.ed.Clear() | |
| @@ -2916,13 +2922,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2916 | 2922 | return false | |
| 2917 | 2923 | } | |
| 2918 | 2924 | // Slash commands run regardless of busy state. Commands that | |
| 2919 | - | // would mutate the transcript or replace the agent (/clear, | |
| 2920 | - | // /compact, /logout, /login, /model) cancel the active turn | |
| 2925 | + | // would mutate the transcript or replace the agent (/new, | |
| 2926 | + | // /clear, /compact, /logout, /login, /model) cancel the active turn | |
| 2921 | 2927 | // first and wait for the goroutine to wind down so they don't | |
| 2922 | 2928 | // race with a streaming response. Safe commands (/help, | |
| 2923 | 2929 | // /jump, /sessions, /jail, /unjail, /exit) run immediately | |
| 2924 | 2930 | // without disturbing the active turn. | |
| 2925 | - | if slashCancelsTurn(head) { | |
| 2931 | + | if i.slashCancelsActiveTurn(head) { | |
| 2926 | 2932 | i.cancelAndWaitForIdle() | |
| 2927 | 2933 | } | |
| 2928 | 2934 | return i.runSlash(ctx, text) | |
| @@ -3331,7 +3337,7 @@ func (i *Interactive) SubmitSlash(text string) { | |||
| 3331 | 3337 | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| 3332 | 3338 | head = text[:idx] | |
| 3333 | 3339 | } | |
| 3334 | - | if slashCancelsTurn(head) { | |
| 3340 | + | if i.slashCancelsActiveTurn(head) { | |
| 3335 | 3341 | i.cancelAndWaitForIdle() | |
| 3336 | 3342 | } | |
| 3337 | 3343 | i.runSlash(i.runCtx, text) | |
| @@ -4721,6 +4727,53 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4721 | 4727 | switch strings.ToLower(parts[0]) { | |
| 4722 | 4728 | case "/exit": | |
| 4723 | 4729 | return true | |
| 4730 | + | case "/new": | |
| 4731 | + | if i.cfg.NewSession == nil { | |
| 4732 | + | i.mu.Lock() | |
| 4733 | + | i.statusErr = "sessions are disabled by --no-session" | |
| 4734 | + | i.statusOK = "" | |
| 4735 | + | i.mu.Unlock() | |
| 4736 | + | break | |
| 4737 | + | } | |
| 4738 | + | if err := i.cfg.NewSession(i.cfg.Provider, i.cfg.Model); err != nil { | |
| 4739 | + | i.mu.Lock() | |
| 4740 | + | i.statusErr = "start new session: " + err.Error() | |
| 4741 | + | i.statusOK = "" | |
| 4742 | + | i.mu.Unlock() | |
| 4743 | + | break | |
| 4744 | + | } | |
| 4745 | + | i.mu.Lock() | |
| 4746 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4747 | + | i.toolOrder = nil | |
| 4748 | + | i.toolGate = map[string]int{} | |
| 4749 | + | i.queued = nil | |
| 4750 | + | i.clipboardImages = nil | |
| 4751 | + | i.resetStreamingStateLocked() | |
| 4752 | + | i.clearPendingCompactTurnLocked() | |
| 4753 | + | i.pendingRescuePrompt = "" | |
| 4754 | + | i.pendingRescueImages = nil | |
| 4755 | + | i.statusErr = "" | |
| 4756 | + | i.statusOK = "started new session" | |
| 4757 | + | i.helpBlock = nil | |
| 4758 | + | i.parkedTurn = 0 | |
| 4759 | + | i.parkedTotal = 0 | |
| 4760 | + | i.scrollOffset = 0 | |
| 4761 | + | i.prevChatLen = 0 | |
| 4762 | + | i.prevChatCols = 0 | |
| 4763 | + | i.extNotes = nil | |
| 4764 | + | i.reloadErrors = nil | |
| 4765 | + | i.cumUsage = provider.Usage{} | |
| 4766 | + | i.lastCtxInput = 0 | |
| 4767 | + | i.inputHistoryIndex = -1 | |
| 4768 | + | i.pendingFork = false | |
| 4769 | + | i.view.Messages = nil | |
| 4770 | + | i.view.TailLimit = 0 | |
| 4771 | + | i.view.InvalidateRenderCache() | |
| 4772 | + | i.mu.Unlock() | |
| 4773 | + | if i.cfg.ConfirmGate != nil { | |
| 4774 | + | i.cfg.ConfirmGate.Reset() | |
| 4775 | + | } | |
| 4776 | + | i.SetPinnedSkillsPending(true) | |
| 4724 | 4777 | case "/clear": | |
| 4725 | 4778 | if i.agent != nil { | |
| 4726 | 4779 | i.agent.SetMessages(nil) | |
| @@ -4742,7 +4795,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4742 | 4795 | i.SetPinnedSkillsPending(true) | |
| 4743 | 4796 | case "/help": | |
| 4744 | 4797 | i.mu.Lock() | |
| 4745 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap) | |
| 4798 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.cfg.NewSession != nil, i.keymap) | |
| 4746 | 4799 | i.statusErr = "" | |
| 4747 | 4800 | i.statusOK = "" | |
| 4748 | 4801 | // Pin the viewport to the newest content so the help block, | |
| @@ -5328,14 +5381,21 @@ func (i *Interactive) submitManualOAuthCode(code string) { | |||
| 5328 | 5381 | }() | |
| 5329 | 5382 | } | |
| 5330 | 5383 | ||
| 5384 | + | // slashCancelsActiveTurn applies command availability to the destructive | |
| 5385 | + | // command classification. In particular, /new must not cancel work when | |
| 5386 | + | // session persistence is disabled and the command cannot run. | |
| 5387 | + | func (i *Interactive) slashCancelsActiveTurn(head string) bool { | |
| 5388 | + | return slashCancelsTurn(head) && !(strings.EqualFold(head, "/new") && i.cfg.NewSession == nil) | |
| 5389 | + | } | |
| 5390 | + | ||
| 5331 | 5391 | // applyModelSelection switches the active model (and provider, if the | |
| 5332 | 5392 | // new model belongs to a different one). It rebuilds the underlying | |
| 5333 | 5393 | // client when needed so the provider wire-protocol matches. | |
| 5334 | 5394 | // cancelAndWaitForIdle cancels the active turn (if any) and blocks | |
| 5335 | 5395 | // briefly until the turn goroutine has updated i.busy = false. Used | |
| 5336 | 5396 | // before destructive slash commands so transcript-mutating work | |
| 5337 | - | // (/clear, /compact, /logout, /login completion, cross-provider | |
| 5338 | - | // /model swap) doesn't race with the still-running stream. | |
| 5397 | + | // (/new, /clear, /compact, /logout, /login completion, | |
| 5398 | + | // cross-provider /model swap) doesn't race with the still-running stream. | |
| 5339 | 5399 | // | |
| 5340 | 5400 | // The wait is bounded; if the turn doesn't release within the timeout | |
| 5341 | 5401 | // we proceed anyway. Worst case is a brief overlap that the agent's | |
| @@ -253,6 +253,11 @@ type InteractiveConfig struct { | |||||
| 253 | // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail. | 253 | // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail. | ||
| 254 | Sandbox *tools.Sandbox | 254 | Sandbox *tools.Sandbox | ||
| 255 | 255 | ||||
| 256 | + | // NewSession persists and closes the current session, then creates and | |||
| 257 | + | // binds an empty session in the same working directory. It is nil when | |||
| 258 | + | // persistence is disabled, including runs started with --no-session. | |||
| 259 | + | NewSession func(providerName, model string) error | |||
| 260 | + | ||||
| 256 | // LoadSession swaps the current session for the one at path. The | 261 | // LoadSession swaps the current session for the one at path. The | ||
| 257 | // callback returns the new agent message slice so the TUI can invalidate. | 262 | // callback returns the new agent message slice so the TUI can invalidate. | ||
| 258 | LoadSession func(path string) error | 263 | LoadSession func(path string) error | ||
| @@ -729,6 +734,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 729 | i.reloadErrors = append(i.reloadErrors, keymapIssues...) | 734 | i.reloadErrors = append(i.reloadErrors, keymapIssues...) | ||
| 730 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | 735 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | ||
| 731 | i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest) | 736 | i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest) | ||
| 737 | + | i.suggest.SetSessionsEnabled(cfg.NewSession != nil) | |||
| 732 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | 738 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | ||
| 733 | if cfg.LlamaCPPConfig != nil { | 739 | if cfg.LlamaCPPConfig != nil { | ||
| 734 | baseURL, _, err := cfg.LlamaCPPConfig() | 740 | baseURL, _, err := cfg.LlamaCPPConfig() | ||
| @@ -1449,8 +1455,8 @@ func (i *Interactive) redraw() { | |||||
| 1449 | i.suggest.SetSkills(list) | 1455 | i.suggest.SetSkills(list) | ||
| 1450 | } | 1456 | } | ||
| 1451 | // Slash popup renders even while the agent is busy so the user | 1457 | // Slash popup renders even while the agent is busy so the user | ||
| 1452 | - | // can queue a destructive command (/clear, /compact, /logout, | 1458 | + | // can queue a destructive command (/new, /clear, /compact, |
| 1453 | - | // /model) or a read-only one (/help, /jump, /sessions, etc.) | 1459 | + | // /logout, /model) or a read-only one (/help, /jump, /sessions, etc.) |
| 1454 | // without waiting for the current turn to finish. The dispatcher | 1460 | // without waiting for the current turn to finish. The dispatcher | ||
| 1455 | // in runSlash already handles the busy case per-command: safe | 1461 | // in runSlash already handles the busy case per-command: safe | ||
| 1456 | // ones run immediately, destructive ones cancel the turn first. | 1462 | // ones run immediately, destructive ones cancel the turn first. | ||
| @@ -2537,7 +2543,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2537 | if !keymapReservedKey(k) { | 2543 | if !keymapReservedKey(k) { | ||
| 2538 | if command := lookupKeymap(i.keymap, k); command != "" { | 2544 | if command := lookupKeymap(i.keymap, k); command != "" { | ||
| 2539 | parts := strings.Fields(command) | 2545 | parts := strings.Fields(command) | ||
| 2540 | - | if len(parts) > 0 && slashCancelsTurn(parts[0]) { | 2546 | + | if len(parts) > 0 && i.slashCancelsActiveTurn(parts[0]) { |
| 2541 | i.cancelAndWaitForIdle() | 2547 | i.cancelAndWaitForIdle() | ||
| 2542 | } | 2548 | } | ||
| 2543 | i.ed.Clear() | 2549 | i.ed.Clear() | ||
| @@ -2916,13 +2922,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2916 | return false | 2922 | return false | ||
| 2917 | } | 2923 | } | ||
| 2918 | // Slash commands run regardless of busy state. Commands that | 2924 | // Slash commands run regardless of busy state. Commands that | ||
| 2919 | - | // would mutate the transcript or replace the agent (/clear, | 2925 | + | // would mutate the transcript or replace the agent (/new, |
| 2920 | - | // /compact, /logout, /login, /model) cancel the active turn | 2926 | + | // /clear, /compact, /logout, /login, /model) cancel the active turn |
| 2921 | // first and wait for the goroutine to wind down so they don't | 2927 | // first and wait for the goroutine to wind down so they don't | ||
| 2922 | // race with a streaming response. Safe commands (/help, | 2928 | // race with a streaming response. Safe commands (/help, | ||
| 2923 | // /jump, /sessions, /jail, /unjail, /exit) run immediately | 2929 | // /jump, /sessions, /jail, /unjail, /exit) run immediately | ||
| 2924 | // without disturbing the active turn. | 2930 | // without disturbing the active turn. | ||
| 2925 | - | if slashCancelsTurn(head) { | 2931 | + | if i.slashCancelsActiveTurn(head) { |
| 2926 | i.cancelAndWaitForIdle() | 2932 | i.cancelAndWaitForIdle() | ||
| 2927 | } | 2933 | } | ||
| 2928 | return i.runSlash(ctx, text) | 2934 | return i.runSlash(ctx, text) | ||
| @@ -3331,7 +3337,7 @@ func (i *Interactive) SubmitSlash(text string) { | |||||
| 3331 | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | 3337 | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | ||
| 3332 | head = text[:idx] | 3338 | head = text[:idx] | ||
| 3333 | } | 3339 | } | ||
| 3334 | - | if slashCancelsTurn(head) { | 3340 | + | if i.slashCancelsActiveTurn(head) { |
| 3335 | i.cancelAndWaitForIdle() | 3341 | i.cancelAndWaitForIdle() | ||
| 3336 | } | 3342 | } | ||
| 3337 | i.runSlash(i.runCtx, text) | 3343 | i.runSlash(i.runCtx, text) | ||
| @@ -4721,6 +4727,53 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4721 | switch strings.ToLower(parts[0]) { | 4727 | switch strings.ToLower(parts[0]) { | ||
| 4722 | case "/exit": | 4728 | case "/exit": | ||
| 4723 | return true | 4729 | return true | ||
| 4730 | + | case "/new": | |||
| 4731 | + | if i.cfg.NewSession == nil { | |||
| 4732 | + | i.mu.Lock() | |||
| 4733 | + | i.statusErr = "sessions are disabled by --no-session" | |||
| 4734 | + | i.statusOK = "" | |||
| 4735 | + | i.mu.Unlock() | |||
| 4736 | + | break | |||
| 4737 | + | } | |||
| 4738 | + | if err := i.cfg.NewSession(i.cfg.Provider, i.cfg.Model); err != nil { | |||
| 4739 | + | i.mu.Lock() | |||
| 4740 | + | i.statusErr = "start new session: " + err.Error() | |||
| 4741 | + | i.statusOK = "" | |||
| 4742 | + | i.mu.Unlock() | |||
| 4743 | + | break | |||
| 4744 | + | } | |||
| 4745 | + | i.mu.Lock() | |||
| 4746 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |||
| 4747 | + | i.toolOrder = nil | |||
| 4748 | + | i.toolGate = map[string]int{} | |||
| 4749 | + | i.queued = nil | |||
| 4750 | + | i.clipboardImages = nil | |||
| 4751 | + | i.resetStreamingStateLocked() | |||
| 4752 | + | i.clearPendingCompactTurnLocked() | |||
| 4753 | + | i.pendingRescuePrompt = "" | |||
| 4754 | + | i.pendingRescueImages = nil | |||
| 4755 | + | i.statusErr = "" | |||
| 4756 | + | i.statusOK = "started new session" | |||
| 4757 | + | i.helpBlock = nil | |||
| 4758 | + | i.parkedTurn = 0 | |||
| 4759 | + | i.parkedTotal = 0 | |||
| 4760 | + | i.scrollOffset = 0 | |||
| 4761 | + | i.prevChatLen = 0 | |||
| 4762 | + | i.prevChatCols = 0 | |||
| 4763 | + | i.extNotes = nil | |||
| 4764 | + | i.reloadErrors = nil | |||
| 4765 | + | i.cumUsage = provider.Usage{} | |||
| 4766 | + | i.lastCtxInput = 0 | |||
| 4767 | + | i.inputHistoryIndex = -1 | |||
| 4768 | + | i.pendingFork = false | |||
| 4769 | + | i.view.Messages = nil | |||
| 4770 | + | i.view.TailLimit = 0 | |||
| 4771 | + | i.view.InvalidateRenderCache() | |||
| 4772 | + | i.mu.Unlock() | |||
| 4773 | + | if i.cfg.ConfirmGate != nil { | |||
| 4774 | + | i.cfg.ConfirmGate.Reset() | |||
| 4775 | + | } | |||
| 4776 | + | i.SetPinnedSkillsPending(true) | |||
| 4724 | case "/clear": | 4777 | case "/clear": | ||
| 4725 | if i.agent != nil { | 4778 | if i.agent != nil { | ||
| 4726 | i.agent.SetMessages(nil) | 4779 | i.agent.SetMessages(nil) | ||
| @@ -4742,7 +4795,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4742 | i.SetPinnedSkillsPending(true) | 4795 | i.SetPinnedSkillsPending(true) | ||
| 4743 | case "/help": | 4796 | case "/help": | ||
| 4744 | i.mu.Lock() | 4797 | i.mu.Lock() | ||
| 4745 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap) | 4798 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.cfg.NewSession != nil, i.keymap) |
| 4746 | i.statusErr = "" | 4799 | i.statusErr = "" | ||
| 4747 | i.statusOK = "" | 4800 | i.statusOK = "" | ||
| 4748 | // Pin the viewport to the newest content so the help block, | 4801 | // Pin the viewport to the newest content so the help block, | ||
| @@ -5328,14 +5381,21 @@ func (i *Interactive) submitManualOAuthCode(code string) { | |||||
| 5328 | }() | 5381 | }() | ||
| 5329 | } | 5382 | } | ||
| 5330 | 5383 | ||||
| 5384 | + | // slashCancelsActiveTurn applies command availability to the destructive | |||
| 5385 | + | // command classification. In particular, /new must not cancel work when | |||
| 5386 | + | // session persistence is disabled and the command cannot run. | |||
| 5387 | + | func (i *Interactive) slashCancelsActiveTurn(head string) bool { | |||
| 5388 | + | return slashCancelsTurn(head) && !(strings.EqualFold(head, "/new") && i.cfg.NewSession == nil) | |||
| 5389 | + | } | |||
| 5390 | + | ||||
| 5331 | // applyModelSelection switches the active model (and provider, if the | 5391 | // applyModelSelection switches the active model (and provider, if the | ||
| 5332 | // new model belongs to a different one). It rebuilds the underlying | 5392 | // new model belongs to a different one). It rebuilds the underlying | ||
| 5333 | // client when needed so the provider wire-protocol matches. | 5393 | // client when needed so the provider wire-protocol matches. | ||
| 5334 | // cancelAndWaitForIdle cancels the active turn (if any) and blocks | 5394 | // cancelAndWaitForIdle cancels the active turn (if any) and blocks | ||
| 5335 | // briefly until the turn goroutine has updated i.busy = false. Used | 5395 | // briefly until the turn goroutine has updated i.busy = false. Used | ||
| 5336 | // before destructive slash commands so transcript-mutating work | 5396 | // before destructive slash commands so transcript-mutating work | ||
| 5337 | - | // (/clear, /compact, /logout, /login completion, cross-provider | 5397 | + | // (/new, /clear, /compact, /logout, /login completion, |
| 5338 | - | // /model swap) doesn't race with the still-running stream. | 5398 | + | // cross-provider /model swap) doesn't race with the still-running stream. |
| 5339 | // | 5399 | // | ||
| 5340 | // The wait is bounded; if the turn doesn't release within the timeout | 5400 | // The wait is bounded; if the turn doesn't release within the timeout | ||
| 5341 | // we proceed anyway. Worst case is a brief overlap that the agent's | 5401 | // we proceed anyway. Worst case is a brief overlap that the agent's | ||
A packages/agent/modes/new_session_test.go +95 -0
| @@ -0,0 +1,95 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestNewSessionUnavailableWithoutPersistence(t *testing.T) { | |
| 13 | + | i := NewInteractive(InteractiveConfig{}) | |
| 14 | + | if i.slashCancelsActiveTurn("/new") { | |
| 15 | + | t.Fatal("unavailable /new would cancel the active turn") | |
| 16 | + | } | |
| 17 | + | i.runSlash(context.Background(), "/new") | |
| 18 | + | ||
| 19 | + | if i.statusErr != "sessions are disabled by --no-session" { | |
| 20 | + | t.Fatalf("status error = %q", i.statusErr) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestNewSessionResetsConversationView(t *testing.T) { | |
| 25 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 26 | + | ag.SetMessages([]provider.Message{{ | |
| 27 | + | Role: provider.RoleUser, | |
| 28 | + | Content: []provider.Content{provider.TextBlock{Text: "old conversation"}}, | |
| 29 | + | }}) | |
| 30 | + | calls := 0 | |
| 31 | + | var gotProvider, gotModel string | |
| 32 | + | gate := core.NewConfirmGate(nil) | |
| 33 | + | gate.AllowAll() | |
| 34 | + | i := NewInteractive(InteractiveConfig{ | |
| 35 | + | Agent: ag, | |
| 36 | + | Provider: "test-provider", | |
| 37 | + | Model: "active-model", | |
| 38 | + | ConfirmGate: gate, | |
| 39 | + | NewSession: func(providerName, model string) error { | |
| 40 | + | calls++ | |
| 41 | + | gotProvider, gotModel = providerName, model | |
| 42 | + | ag.SetMessages(nil) | |
| 43 | + | return nil | |
| 44 | + | }, | |
| 45 | + | }) | |
| 46 | + | i.cumUsage = provider.Usage{InputTokens: 10} | |
| 47 | + | i.lastCtxInput = 10 | |
| 48 | + | i.extNotes = []string{"old notice"} | |
| 49 | + | ||
| 50 | + | i.runSlash(context.Background(), "/new") | |
| 51 | + | ||
| 52 | + | if calls != 1 { | |
| 53 | + | t.Fatalf("NewSession calls = %d, want 1", calls) | |
| 54 | + | } | |
| 55 | + | if gotProvider != "test-provider" || gotModel != "active-model" { | |
| 56 | + | t.Fatalf("NewSession model = %q/%q", gotProvider, gotModel) | |
| 57 | + | } | |
| 58 | + | if len(ag.Messages()) != 0 || len(i.view.Messages) != 0 { | |
| 59 | + | t.Fatal("new session retained the previous transcript") | |
| 60 | + | } | |
| 61 | + | if i.cumUsage != (provider.Usage{}) || i.lastCtxInput != 0 { | |
| 62 | + | t.Fatalf("usage was not reset: cumulative=%+v context=%d", i.cumUsage, i.lastCtxInput) | |
| 63 | + | } | |
| 64 | + | if len(i.extNotes) != 0 { | |
| 65 | + | t.Fatalf("extension notices were not cleared: %v", i.extNotes) | |
| 66 | + | } | |
| 67 | + | if decision := gate.DecideToolCall(core.ToolCallConfirmation{Name: "bash"}); decision.Allow { | |
| 68 | + | t.Fatal("new session retained session-scoped confirmation approval") | |
| 69 | + | } | |
| 70 | + | if i.statusOK != "started new session" || i.statusErr != "" { | |
| 71 | + | t.Fatalf("status = %q, %q", i.statusOK, i.statusErr) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestNewSessionFailureKeepsConversationView(t *testing.T) { | |
| 76 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 77 | + | messages := []provider.Message{{ | |
| 78 | + | Role: provider.RoleUser, | |
| 79 | + | Content: []provider.Content{provider.TextBlock{Text: "still active"}}, | |
| 80 | + | }} | |
| 81 | + | ag.SetMessages(messages) | |
| 82 | + | i := NewInteractive(InteractiveConfig{ | |
| 83 | + | Agent: ag, | |
| 84 | + | NewSession: func(string, string) error { return errors.New("disk full") }, | |
| 85 | + | }) | |
| 86 | + | ||
| 87 | + | i.runSlash(context.Background(), "/new") | |
| 88 | + | ||
| 89 | + | if len(ag.Messages()) != 1 || len(i.view.Messages) != 1 { | |
| 90 | + | t.Fatal("failed session creation cleared the current transcript") | |
| 91 | + | } | |
| 92 | + | if i.statusErr != "start new session: disk full" { | |
| 93 | + | t.Fatalf("status error = %q", i.statusErr) | |
| 94 | + | } | |
| 95 | + | } | |
| @@ -0,0 +1,95 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestNewSessionUnavailableWithoutPersistence(t *testing.T) { | |||
| 13 | + | i := NewInteractive(InteractiveConfig{}) | |||
| 14 | + | if i.slashCancelsActiveTurn("/new") { | |||
| 15 | + | t.Fatal("unavailable /new would cancel the active turn") | |||
| 16 | + | } | |||
| 17 | + | i.runSlash(context.Background(), "/new") | |||
| 18 | + | ||||
| 19 | + | if i.statusErr != "sessions are disabled by --no-session" { | |||
| 20 | + | t.Fatalf("status error = %q", i.statusErr) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestNewSessionResetsConversationView(t *testing.T) { | |||
| 25 | + | ag := core.NewAgent(nil, "model", "", nil) | |||
| 26 | + | ag.SetMessages([]provider.Message{{ | |||
| 27 | + | Role: provider.RoleUser, | |||
| 28 | + | Content: []provider.Content{provider.TextBlock{Text: "old conversation"}}, | |||
| 29 | + | }}) | |||
| 30 | + | calls := 0 | |||
| 31 | + | var gotProvider, gotModel string | |||
| 32 | + | gate := core.NewConfirmGate(nil) | |||
| 33 | + | gate.AllowAll() | |||
| 34 | + | i := NewInteractive(InteractiveConfig{ | |||
| 35 | + | Agent: ag, | |||
| 36 | + | Provider: "test-provider", | |||
| 37 | + | Model: "active-model", | |||
| 38 | + | ConfirmGate: gate, | |||
| 39 | + | NewSession: func(providerName, model string) error { | |||
| 40 | + | calls++ | |||
| 41 | + | gotProvider, gotModel = providerName, model | |||
| 42 | + | ag.SetMessages(nil) | |||
| 43 | + | return nil | |||
| 44 | + | }, | |||
| 45 | + | }) | |||
| 46 | + | i.cumUsage = provider.Usage{InputTokens: 10} | |||
| 47 | + | i.lastCtxInput = 10 | |||
| 48 | + | i.extNotes = []string{"old notice"} | |||
| 49 | + | ||||
| 50 | + | i.runSlash(context.Background(), "/new") | |||
| 51 | + | ||||
| 52 | + | if calls != 1 { | |||
| 53 | + | t.Fatalf("NewSession calls = %d, want 1", calls) | |||
| 54 | + | } | |||
| 55 | + | if gotProvider != "test-provider" || gotModel != "active-model" { | |||
| 56 | + | t.Fatalf("NewSession model = %q/%q", gotProvider, gotModel) | |||
| 57 | + | } | |||
| 58 | + | if len(ag.Messages()) != 0 || len(i.view.Messages) != 0 { | |||
| 59 | + | t.Fatal("new session retained the previous transcript") | |||
| 60 | + | } | |||
| 61 | + | if i.cumUsage != (provider.Usage{}) || i.lastCtxInput != 0 { | |||
| 62 | + | t.Fatalf("usage was not reset: cumulative=%+v context=%d", i.cumUsage, i.lastCtxInput) | |||
| 63 | + | } | |||
| 64 | + | if len(i.extNotes) != 0 { | |||
| 65 | + | t.Fatalf("extension notices were not cleared: %v", i.extNotes) | |||
| 66 | + | } | |||
| 67 | + | if decision := gate.DecideToolCall(core.ToolCallConfirmation{Name: "bash"}); decision.Allow { | |||
| 68 | + | t.Fatal("new session retained session-scoped confirmation approval") | |||
| 69 | + | } | |||
| 70 | + | if i.statusOK != "started new session" || i.statusErr != "" { | |||
| 71 | + | t.Fatalf("status = %q, %q", i.statusOK, i.statusErr) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestNewSessionFailureKeepsConversationView(t *testing.T) { | |||
| 76 | + | ag := core.NewAgent(nil, "model", "", nil) | |||
| 77 | + | messages := []provider.Message{{ | |||
| 78 | + | Role: provider.RoleUser, | |||
| 79 | + | Content: []provider.Content{provider.TextBlock{Text: "still active"}}, | |||
| 80 | + | }} | |||
| 81 | + | ag.SetMessages(messages) | |||
| 82 | + | i := NewInteractive(InteractiveConfig{ | |||
| 83 | + | Agent: ag, | |||
| 84 | + | NewSession: func(string, string) error { return errors.New("disk full") }, | |||
| 85 | + | }) | |||
| 86 | + | ||||
| 87 | + | i.runSlash(context.Background(), "/new") | |||
| 88 | + | ||||
| 89 | + | if len(ag.Messages()) != 1 || len(i.view.Messages) != 1 { | |||
| 90 | + | t.Fatal("failed session creation cleared the current transcript") | |||
| 91 | + | } | |||
| 92 | + | if i.statusErr != "start new session: disk full" { | |||
| 93 | + | t.Fatalf("status error = %q", i.statusErr) | |||
| 94 | + | } | |||
| 95 | + | } | |||
M packages/agent/modes/slash_suggest.go +7 -2
| @@ -28,7 +28,7 @@ type slashCommand struct { | |||
| 28 | 28 | // the streaming response without trouble. | |
| 29 | 29 | func slashCancelsTurn(head string) bool { | |
| 30 | 30 | switch strings.ToLower(head) { | |
| 31 | - | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": | |
| 31 | + | case "/new", "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": | |
| 32 | 32 | return true | |
| 33 | 33 | } | |
| 34 | 34 | return false | |
| @@ -44,6 +44,7 @@ var slashCatalog = []slashCommand{ | |||
| 44 | 44 | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 45 | 45 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 46 | 46 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 47 | + | {Name: "/new", Desc: "start a fresh session in this directory"}, | |
| 47 | 48 | {Name: "/session", Desc: "manage the current session"}, | |
| 48 | 49 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | |
| 49 | 50 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | |
| @@ -78,6 +79,7 @@ type slashSuggester struct { | |||
| 78 | 79 | // jailed and llamaConfigured drive state-dependent command visibility. | |
| 79 | 80 | jailed bool | |
| 80 | 81 | llamaConfigured bool | |
| 82 | + | sessionsEnabled bool | |
| 81 | 83 | ||
| 82 | 84 | // extra are commands contributed by extensions, refreshed each | |
| 83 | 85 | // frame from the extension manager. Empty when no extensions | |
| @@ -195,6 +197,9 @@ func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } | |||
| 195 | 197 | // available. | |
| 196 | 198 | func (s *slashSuggester) SetLlamaConfigured(configured bool) { s.llamaConfigured = configured } | |
| 197 | 199 | ||
| 200 | + | // SetSessionsEnabled controls commands that require persisted sessions. | |
| 201 | + | func (s *slashSuggester) SetSessionsEnabled(enabled bool) { s.sessionsEnabled = enabled } | |
| 202 | + | ||
| 198 | 203 | // allCatalog returns slashCatalog plus the current extra commands | |
| 199 | 204 | // (extension-registered) with a header divider between the two | |
| 200 | 205 | // groups. Extra entries are only kept if they don't collide with | |
| @@ -235,7 +240,7 @@ func (s *slashSuggester) baseCatalog() []slashCommand { | |||
| 235 | 240 | } | |
| 236 | 241 | out := make([]slashCommand, 0, len(slashCatalog)-1) | |
| 237 | 242 | for _, c := range slashCatalog { | |
| 238 | - | if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) { | |
| 243 | + | if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) || (c.Name == "/new" && !s.sessionsEnabled) { | |
| 239 | 244 | continue | |
| 240 | 245 | } | |
| 241 | 246 | out = append(out, c) | |
| @@ -28,7 +28,7 @@ type slashCommand struct { | |||||
| 28 | // the streaming response without trouble. | 28 | // the streaming response without trouble. | ||
| 29 | func slashCancelsTurn(head string) bool { | 29 | func slashCancelsTurn(head string) bool { | ||
| 30 | switch strings.ToLower(head) { | 30 | switch strings.ToLower(head) { | ||
| 31 | - | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": | 31 | + | case "/new", "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": |
| 32 | return true | 32 | return true | ||
| 33 | } | 33 | } | ||
| 34 | return false | 34 | return false | ||
| @@ -44,6 +44,7 @@ var slashCatalog = []slashCommand{ | |||||
| 44 | {Name: "/reasoning", Desc: "set the reasoning level"}, | 44 | {Name: "/reasoning", Desc: "set the reasoning level"}, | ||
| 45 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | 45 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | ||
| 46 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | 46 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | ||
| 47 | + | {Name: "/new", Desc: "start a fresh session in this directory"}, | |||
| 47 | {Name: "/session", Desc: "manage the current session"}, | 48 | {Name: "/session", Desc: "manage the current session"}, | ||
| 48 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | 49 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | ||
| 49 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | 50 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | ||
| @@ -78,6 +79,7 @@ type slashSuggester struct { | |||||
| 78 | // jailed and llamaConfigured drive state-dependent command visibility. | 79 | // jailed and llamaConfigured drive state-dependent command visibility. | ||
| 79 | jailed bool | 80 | jailed bool | ||
| 80 | llamaConfigured bool | 81 | llamaConfigured bool | ||
| 82 | + | sessionsEnabled bool | |||
| 81 | 83 | ||||
| 82 | // extra are commands contributed by extensions, refreshed each | 84 | // extra are commands contributed by extensions, refreshed each | ||
| 83 | // frame from the extension manager. Empty when no extensions | 85 | // frame from the extension manager. Empty when no extensions | ||
| @@ -195,6 +197,9 @@ func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } | |||||
| 195 | // available. | 197 | // available. | ||
| 196 | func (s *slashSuggester) SetLlamaConfigured(configured bool) { s.llamaConfigured = configured } | 198 | func (s *slashSuggester) SetLlamaConfigured(configured bool) { s.llamaConfigured = configured } | ||
| 197 | 199 | ||||
| 200 | + | // SetSessionsEnabled controls commands that require persisted sessions. | |||
| 201 | + | func (s *slashSuggester) SetSessionsEnabled(enabled bool) { s.sessionsEnabled = enabled } | |||
| 202 | + | ||||
| 198 | // allCatalog returns slashCatalog plus the current extra commands | 203 | // allCatalog returns slashCatalog plus the current extra commands | ||
| 199 | // (extension-registered) with a header divider between the two | 204 | // (extension-registered) with a header divider between the two | ||
| 200 | // groups. Extra entries are only kept if they don't collide with | 205 | // groups. Extra entries are only kept if they don't collide with | ||
| @@ -235,7 +240,7 @@ func (s *slashSuggester) baseCatalog() []slashCommand { | |||||
| 235 | } | 240 | } | ||
| 236 | out := make([]slashCommand, 0, len(slashCatalog)-1) | 241 | out := make([]slashCommand, 0, len(slashCatalog)-1) | ||
| 237 | for _, c := range slashCatalog { | 242 | for _, c := range slashCatalog { | ||
| 238 | - | if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) { | 243 | + | if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) || (c.Name == "/new" && !s.sessionsEnabled) { |
| 239 | continue | 244 | continue | ||
| 240 | } | 245 | } | ||
| 241 | out = append(out, c) | 246 | out = append(out, c) | ||
M packages/agent/modes/slash_suggest_test.go +14 -0
| @@ -37,6 +37,17 @@ func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) { | |||
| 37 | 37 | } | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | + | func TestSlashSuggesterShowsNewOnlyWithSessions(t *testing.T) { | |
| 41 | + | s := newSlashSuggester() | |
| 42 | + | if got := commandNames(s.matches("/new")); contains(got, "/new") { | |
| 43 | + | t.Fatalf("/new visible with sessions disabled: %v", got) | |
| 44 | + | } | |
| 45 | + | s.SetSessionsEnabled(true) | |
| 46 | + | if got := commandNames(s.matches("/new")); !contains(got, "/new") { | |
| 47 | + | t.Fatalf("/new missing with sessions enabled: %v", got) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 40 | 51 | func TestSlashSuggesterHasSwarm(t *testing.T) { | |
| 41 | 52 | s := newSlashSuggester() | |
| 42 | 53 | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | |
| @@ -84,6 +95,9 @@ func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |||
| 84 | 95 | if !slashCancelsTurn("/CLEAR") { | |
| 85 | 96 | t.Fatal("/CLEAR did not retain /clear cancellation semantics") | |
| 86 | 97 | } | |
| 98 | + | if !slashCancelsTurn("/NEW") { | |
| 99 | + | t.Fatal("/NEW did not retain /new cancellation semantics") | |
| 100 | + | } | |
| 87 | 101 | } | |
| 88 | 102 | ||
| 89 | 103 | func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) { | |
| @@ -37,6 +37,17 @@ func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) { | |||||
| 37 | } | 37 | } | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | + | func TestSlashSuggesterShowsNewOnlyWithSessions(t *testing.T) { | |||
| 41 | + | s := newSlashSuggester() | |||
| 42 | + | if got := commandNames(s.matches("/new")); contains(got, "/new") { | |||
| 43 | + | t.Fatalf("/new visible with sessions disabled: %v", got) | |||
| 44 | + | } | |||
| 45 | + | s.SetSessionsEnabled(true) | |||
| 46 | + | if got := commandNames(s.matches("/new")); !contains(got, "/new") { | |||
| 47 | + | t.Fatalf("/new missing with sessions enabled: %v", got) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 40 | func TestSlashSuggesterHasSwarm(t *testing.T) { | 51 | func TestSlashSuggesterHasSwarm(t *testing.T) { | ||
| 41 | s := newSlashSuggester() | 52 | s := newSlashSuggester() | ||
| 42 | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | 53 | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | ||
| @@ -84,6 +95,9 @@ func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |||||
| 84 | if !slashCancelsTurn("/CLEAR") { | 95 | if !slashCancelsTurn("/CLEAR") { | ||
| 85 | t.Fatal("/CLEAR did not retain /clear cancellation semantics") | 96 | t.Fatal("/CLEAR did not retain /clear cancellation semantics") | ||
| 86 | } | 97 | } | ||
| 98 | + | if !slashCancelsTurn("/NEW") { | |||
| 99 | + | t.Fatal("/NEW did not retain /new cancellation semantics") | |||
| 100 | + | } | |||
| 87 | } | 101 | } | ||
| 88 | 102 | ||||
| 89 | func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) { | 103 | func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) { | ||