feat(session): resume sessions by UUID
- commit
- 443b17656526c2be9e3f589493b23f45b243586d
- author
- brice · · 2026-08-08 06:11 UTC
- parent
- ab30a57
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +429 -9
M README.md +2 -1
| @@ -194,6 +194,7 @@ zut -p --stats stats.json "task" # print final text and write generation stats | |||
| 194 | 194 | zut --json "refactor main.go" # newline-delimited json events, exit | |
| 195 | 195 | zut --continue # resume the most recent session for this cwd | |
| 196 | 196 | zut --resume # pick a session to resume | |
| 197 | + | zut --resume <UUID> # resume a persisted session by UUID | |
| 197 | 198 | zut --list-models # show supported models | |
| 198 | 199 | zut --help | |
| 199 | 200 | ``` | |
| @@ -214,7 +215,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 214 | 215 | | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. | | |
| 215 | 216 | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. | | |
| 216 | 217 | | `-c`, `--continue` | Resume the latest session for this cwd. | | |
| 217 | - | | `-r`, `--resume` | Pick a session to resume. | | |
| 218 | + | | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. | | |
| 218 | 219 | | `--session <path>` | Resume a specific session file. | | |
| 219 | 220 | | `--no-session` | Don't read or write session files. | | |
| 220 | 221 | | `--cwd <path>` | Use `<path>` as the working directory. | | |
| @@ -194,6 +194,7 @@ zut -p --stats stats.json "task" # print final text and write generation stats | |||||
| 194 | zut --json "refactor main.go" # newline-delimited json events, exit | 194 | zut --json "refactor main.go" # newline-delimited json events, exit | ||
| 195 | zut --continue # resume the most recent session for this cwd | 195 | zut --continue # resume the most recent session for this cwd | ||
| 196 | zut --resume # pick a session to resume | 196 | zut --resume # pick a session to resume | ||
| 197 | + | zut --resume <UUID> # resume a persisted session by UUID | |||
| 197 | zut --list-models # show supported models | 198 | zut --list-models # show supported models | ||
| 198 | zut --help | 199 | zut --help | ||
| 199 | ``` | 200 | ``` | ||
| @@ -214,7 +215,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 214 | | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. | | 215 | | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. | | ||
| 215 | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. | | 216 | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. | | ||
| 216 | | `-c`, `--continue` | Resume the latest session for this cwd. | | 217 | | `-c`, `--continue` | Resume the latest session for this cwd. | | ||
| 217 | - | | `-r`, `--resume` | Pick a session to resume. | | 218 | + | | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. | |
| 218 | | `--session <path>` | Resume a specific session file. | | 219 | | `--session <path>` | Resume a specific session file. | | ||
| 219 | | `--no-session` | Don't read or write session files. | | 220 | | `--no-session` | Don't read or write session files. | | ||
| 220 | | `--cwd <path>` | Use `<path>` as the working directory. | | 221 | | `--cwd <path>` | Use `<path>` as the working directory. | | ||
M packages/agent/args.go +13 -5
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | 10 | "github.com/bnema/zut/packages/agent/tools" | |
| 11 | 11 | "github.com/bnema/zut/packages/tui" | |
| 12 | + | "github.com/google/uuid" | |
| 12 | 13 | "golang.org/x/term" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -57,10 +58,11 @@ type Args struct { | |||
| 57 | 58 | // survive a parent config change. | |
| 58 | 59 | FastModeSet bool | |
| 59 | 60 | ||
| 60 | - | Continue bool | |
| 61 | - | Resume bool | |
| 62 | - | Session string | |
| 63 | - | NoSess bool | |
| 61 | + | Continue bool | |
| 62 | + | Resume bool | |
| 63 | + | ResumeSessionID string | |
| 64 | + | Session string | |
| 65 | + | NoSess bool | |
| 64 | 66 | ||
| 65 | 67 | CWD string | |
| 66 | 68 | NoTools bool | |
| @@ -195,6 +197,12 @@ func ParseArgs(in []string) (Args, error) { | |||
| 195 | 197 | a.Continue = true | |
| 196 | 198 | case "-r", "--resume": | |
| 197 | 199 | a.Resume = true | |
| 200 | + | if i+1 < len(in) { | |
| 201 | + | if id, err := uuid.Parse(in[i+1]); err == nil { | |
| 202 | + | a.ResumeSessionID = id.String() | |
| 203 | + | i++ | |
| 204 | + | } | |
| 205 | + | } | |
| 198 | 206 | case "--no-session": | |
| 199 | 207 | a.NoSess = true | |
| 200 | 208 | case "--no-tools": | |
| @@ -510,7 +518,7 @@ func PrintHelp(version string) { | |||
| 510 | 518 | row{"--system-prompt TEXT", "replace the default system prompt"}, | |
| 511 | 519 | row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"}, | |
| 512 | 520 | row{"-c, --continue", "continue the most recent session for this cwd"}, | |
| 513 | - | row{"-r, --resume", "pick a session to resume"}, | |
| 521 | + | row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"}, | |
| 514 | 522 | row{"--session PATH", "resume a specific session file"}, | |
| 515 | 523 | row{"--no-session", "do not read or write a session file"}, | |
| 516 | 524 | ) | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/zut/packages/agent/subagents" | 9 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 10 | "github.com/bnema/zut/packages/agent/tools" | 10 | "github.com/bnema/zut/packages/agent/tools" | ||
| 11 | "github.com/bnema/zut/packages/tui" | 11 | "github.com/bnema/zut/packages/tui" | ||
| 12 | + | "github.com/google/uuid" | |||
| 12 | "golang.org/x/term" | 13 | "golang.org/x/term" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| @@ -57,10 +58,11 @@ type Args struct { | |||||
| 57 | // survive a parent config change. | 58 | // survive a parent config change. | ||
| 58 | FastModeSet bool | 59 | FastModeSet bool | ||
| 59 | 60 | ||||
| 60 | - | Continue bool | 61 | + | Continue bool |
| 61 | - | Resume bool | 62 | + | Resume bool |
| 62 | - | Session string | 63 | + | ResumeSessionID string |
| 63 | - | NoSess bool | 64 | + | Session string |
| 65 | + | NoSess bool | |||
| 64 | 66 | ||||
| 65 | CWD string | 67 | CWD string | ||
| 66 | NoTools bool | 68 | NoTools bool | ||
| @@ -195,6 +197,12 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 195 | a.Continue = true | 197 | a.Continue = true | ||
| 196 | case "-r", "--resume": | 198 | case "-r", "--resume": | ||
| 197 | a.Resume = true | 199 | a.Resume = true | ||
| 200 | + | if i+1 < len(in) { | |||
| 201 | + | if id, err := uuid.Parse(in[i+1]); err == nil { | |||
| 202 | + | a.ResumeSessionID = id.String() | |||
| 203 | + | i++ | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 198 | case "--no-session": | 206 | case "--no-session": | ||
| 199 | a.NoSess = true | 207 | a.NoSess = true | ||
| 200 | case "--no-tools": | 208 | case "--no-tools": | ||
| @@ -510,7 +518,7 @@ func PrintHelp(version string) { | |||||
| 510 | row{"--system-prompt TEXT", "replace the default system prompt"}, | 518 | row{"--system-prompt TEXT", "replace the default system prompt"}, | ||
| 511 | row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"}, | 519 | row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"}, | ||
| 512 | row{"-c, --continue", "continue the most recent session for this cwd"}, | 520 | row{"-c, --continue", "continue the most recent session for this cwd"}, | ||
| 513 | - | row{"-r, --resume", "pick a session to resume"}, | 521 | + | row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"}, |
| 514 | row{"--session PATH", "resume a specific session file"}, | 522 | row{"--session PATH", "resume a specific session file"}, | ||
| 515 | row{"--no-session", "do not read or write a session file"}, | 523 | row{"--no-session", "do not read or write a session file"}, | ||
| 516 | ) | 524 | ) | ||
M packages/agent/args_test.go +29 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/zut/packages/agent/subagents" | |
| 7 | + | "github.com/google/uuid" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | func TestParseArgsSubagentAndReasoning(t *testing.T) { | |
| @@ -26,6 +27,34 @@ func TestParseArgsNoLSP(t *testing.T) { | |||
| 26 | 27 | } | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 30 | + | func TestParseArgsResumeOptionalSessionID(t *testing.T) { | |
| 31 | + | id := uuid.New() | |
| 32 | + | ||
| 33 | + | picker, err := ParseArgs([]string{"--resume"}) | |
| 34 | + | if err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if !picker.Resume || picker.ResumeSessionID != "" || picker.Prompt != "" { | |
| 38 | + | t.Fatalf("no-argument resume = %#v, want picker behavior", picker) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | args, err := ParseArgs([]string{"--resume", id.String(), "continue this"}) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if !args.Resume || args.ResumeSessionID != id.String() || args.Prompt != "continue this" { | |
| 46 | + | t.Fatalf("resume UUID args = %#v", args) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | prompt, err := ParseArgs([]string{"--resume", "not-a-session-id"}) | |
| 50 | + | if err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | if prompt.ResumeSessionID != "" || prompt.Prompt != "not-a-session-id" { | |
| 54 | + | t.Fatalf("non-UUID resume argument = %#v, want existing prompt behavior", prompt) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 29 | 58 | func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) { | |
| 30 | 59 | args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"}) | |
| 31 | 60 | if err != nil { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/zut/packages/agent/subagents" | 6 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 7 | + | "github.com/google/uuid" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | func TestParseArgsSubagentAndReasoning(t *testing.T) { | 10 | func TestParseArgsSubagentAndReasoning(t *testing.T) { | ||
| @@ -26,6 +27,34 @@ func TestParseArgsNoLSP(t *testing.T) { | |||||
| 26 | } | 27 | } | ||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 30 | + | func TestParseArgsResumeOptionalSessionID(t *testing.T) { | |||
| 31 | + | id := uuid.New() | |||
| 32 | + | ||||
| 33 | + | picker, err := ParseArgs([]string{"--resume"}) | |||
| 34 | + | if err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | if !picker.Resume || picker.ResumeSessionID != "" || picker.Prompt != "" { | |||
| 38 | + | t.Fatalf("no-argument resume = %#v, want picker behavior", picker) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | args, err := ParseArgs([]string{"--resume", id.String(), "continue this"}) | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | if !args.Resume || args.ResumeSessionID != id.String() || args.Prompt != "continue this" { | |||
| 46 | + | t.Fatalf("resume UUID args = %#v", args) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | prompt, err := ParseArgs([]string{"--resume", "not-a-session-id"}) | |||
| 50 | + | if err != nil { | |||
| 51 | + | t.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | if prompt.ResumeSessionID != "" || prompt.Prompt != "not-a-session-id" { | |||
| 54 | + | t.Fatalf("non-UUID resume argument = %#v, want existing prompt behavior", prompt) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 29 | func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) { | 58 | func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) { | ||
| 30 | args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"}) | 59 | args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"}) | ||
| 31 | if err != nil { | 60 | if err != nil { | ||
M packages/agent/cli.go +55 -3
| @@ -1744,6 +1744,20 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1744 | 1744 | } | |
| 1745 | 1745 | } | |
| 1746 | 1746 | announceSession(extMgr, sess) | |
| 1747 | + | // Print the hint after the session close defer below and all terminal/TUI | |
| 1748 | + | // cleanup defers. Close removes a fresh empty session, so only a session | |
| 1749 | + | // that remains discoverable through the hinted command gets a hint. | |
| 1750 | + | defer func() { | |
| 1751 | + | if !iv.ExitedViaCtrlC() { | |
| 1752 | + | return | |
| 1753 | + | } | |
| 1754 | + | persistMu.Lock() | |
| 1755 | + | current := sess | |
| 1756 | + | persistMu.Unlock() | |
| 1757 | + | if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" { | |
| 1758 | + | fmt.Print(resumeSessionHint(resumeID)) | |
| 1759 | + | } | |
| 1760 | + | }() | |
| 1747 | 1761 | defer func() { | |
| 1748 | 1762 | persistMu.Lock() | |
| 1749 | 1763 | defer persistMu.Unlock() | |
| @@ -2581,6 +2595,30 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2581 | 2595 | return runErr | |
| 2582 | 2596 | } | |
| 2583 | 2597 | ||
| 2598 | + | func resumableSessionID(root string, sess *core.Session) string { | |
| 2599 | + | if sess == nil || sess.ID == "" || sess.Path == "" { | |
| 2600 | + | return "" | |
| 2601 | + | } | |
| 2602 | + | path, err := core.FindManagedSessionByID(root, sess.ID) | |
| 2603 | + | if err != nil || !sameSessionPath(path, sess.Path) { | |
| 2604 | + | return "" | |
| 2605 | + | } | |
| 2606 | + | return sess.ID | |
| 2607 | + | } | |
| 2608 | + | ||
| 2609 | + | func sameSessionPath(left, right string) bool { | |
| 2610 | + | leftAbs, leftErr := filepath.Abs(left) | |
| 2611 | + | rightAbs, rightErr := filepath.Abs(right) | |
| 2612 | + | if leftErr == nil && rightErr == nil { | |
| 2613 | + | return filepath.Clean(leftAbs) == filepath.Clean(rightAbs) | |
| 2614 | + | } | |
| 2615 | + | return filepath.Clean(left) == filepath.Clean(right) | |
| 2616 | + | } | |
| 2617 | + | ||
| 2618 | + | func resumeSessionHint(id string) string { | |
| 2619 | + | return fmt.Sprintf("Resume this session with: zut --resume %s\n", id) | |
| 2620 | + | } | |
| 2621 | + | ||
| 2584 | 2622 | func agentSessionsRoot(root string, args Args) string { | |
| 2585 | 2623 | if args.AgentName == "" { | |
| 2586 | 2624 | return root | |
| @@ -2595,10 +2633,13 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 2595 | 2633 | return nil, nil | |
| 2596 | 2634 | } | |
| 2597 | 2635 | // Sweep meta-only files left over from older zut versions (and from | |
| 2598 | - | // any session that crashed before its first AppendMessage). Cheap; | |
| 2599 | - | // reads the first few bytes of each file in the cwd's session dir. | |
| 2636 | + | // any session that crashed before its first AppendMessage) for the | |
| 2637 | + | // cwd-scoped picker paths. Explicit UUID lookup stays strict so it | |
| 2638 | + | // can report metadata failures instead of turning them into misses. | |
| 2600 | 2639 | sessionsRoot := agentSessionsRoot(ZutHome(), args) | |
| 2601 | - | core.PruneEmptySessions(sessionsRoot, args.CWD) | |
| 2640 | + | if args.ResumeSessionID == "" { | |
| 2641 | + | core.PruneEmptySessions(sessionsRoot, args.CWD) | |
| 2642 | + | } | |
| 2602 | 2643 | var ( | |
| 2603 | 2644 | s *core.Session | |
| 2604 | 2645 | msgs []provider.Message | |
| @@ -2625,6 +2666,17 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 2625 | 2666 | s, msgs, err = core.OpenSession(latest) | |
| 2626 | 2667 | } | |
| 2627 | 2668 | case args.Resume: | |
| 2669 | + | if args.ResumeSessionID != "" { | |
| 2670 | + | picked, lookupErr := core.FindManagedSessionByID(ZutHome(), args.ResumeSessionID) | |
| 2671 | + | if lookupErr != nil { | |
| 2672 | + | return nil, lookupErr | |
| 2673 | + | } | |
| 2674 | + | if picked == "" { | |
| 2675 | + | return nil, fmt.Errorf("session %q not found", args.ResumeSessionID) | |
| 2676 | + | } | |
| 2677 | + | s, msgs, err = core.OpenSession(picked) | |
| 2678 | + | break | |
| 2679 | + | } | |
| 2628 | 2680 | picked, perr := pickSession(sessionsRoot, args.CWD) | |
| 2629 | 2681 | if perr != nil { | |
| 2630 | 2682 | return nil, perr | |
| @@ -1744,6 +1744,20 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1744 | } | 1744 | } | ||
| 1745 | } | 1745 | } | ||
| 1746 | announceSession(extMgr, sess) | 1746 | announceSession(extMgr, sess) | ||
| 1747 | + | // Print the hint after the session close defer below and all terminal/TUI | |||
| 1748 | + | // cleanup defers. Close removes a fresh empty session, so only a session | |||
| 1749 | + | // that remains discoverable through the hinted command gets a hint. | |||
| 1750 | + | defer func() { | |||
| 1751 | + | if !iv.ExitedViaCtrlC() { | |||
| 1752 | + | return | |||
| 1753 | + | } | |||
| 1754 | + | persistMu.Lock() | |||
| 1755 | + | current := sess | |||
| 1756 | + | persistMu.Unlock() | |||
| 1757 | + | if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" { | |||
| 1758 | + | fmt.Print(resumeSessionHint(resumeID)) | |||
| 1759 | + | } | |||
| 1760 | + | }() | |||
| 1747 | defer func() { | 1761 | defer func() { | ||
| 1748 | persistMu.Lock() | 1762 | persistMu.Lock() | ||
| 1749 | defer persistMu.Unlock() | 1763 | defer persistMu.Unlock() | ||
| @@ -2581,6 +2595,30 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2581 | return runErr | 2595 | return runErr | ||
| 2582 | } | 2596 | } | ||
| 2583 | 2597 | ||||
| 2598 | + | func resumableSessionID(root string, sess *core.Session) string { | |||
| 2599 | + | if sess == nil || sess.ID == "" || sess.Path == "" { | |||
| 2600 | + | return "" | |||
| 2601 | + | } | |||
| 2602 | + | path, err := core.FindManagedSessionByID(root, sess.ID) | |||
| 2603 | + | if err != nil || !sameSessionPath(path, sess.Path) { | |||
| 2604 | + | return "" | |||
| 2605 | + | } | |||
| 2606 | + | return sess.ID | |||
| 2607 | + | } | |||
| 2608 | + | ||||
| 2609 | + | func sameSessionPath(left, right string) bool { | |||
| 2610 | + | leftAbs, leftErr := filepath.Abs(left) | |||
| 2611 | + | rightAbs, rightErr := filepath.Abs(right) | |||
| 2612 | + | if leftErr == nil && rightErr == nil { | |||
| 2613 | + | return filepath.Clean(leftAbs) == filepath.Clean(rightAbs) | |||
| 2614 | + | } | |||
| 2615 | + | return filepath.Clean(left) == filepath.Clean(right) | |||
| 2616 | + | } | |||
| 2617 | + | ||||
| 2618 | + | func resumeSessionHint(id string) string { | |||
| 2619 | + | return fmt.Sprintf("Resume this session with: zut --resume %s\n", id) | |||
| 2620 | + | } | |||
| 2621 | + | ||||
| 2584 | func agentSessionsRoot(root string, args Args) string { | 2622 | func agentSessionsRoot(root string, args Args) string { | ||
| 2585 | if args.AgentName == "" { | 2623 | if args.AgentName == "" { | ||
| 2586 | return root | 2624 | return root | ||
| @@ -2595,10 +2633,13 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 2595 | return nil, nil | 2633 | return nil, nil | ||
| 2596 | } | 2634 | } | ||
| 2597 | // Sweep meta-only files left over from older zut versions (and from | 2635 | // Sweep meta-only files left over from older zut versions (and from | ||
| 2598 | - | // any session that crashed before its first AppendMessage). Cheap; | 2636 | + | // any session that crashed before its first AppendMessage) for the |
| 2599 | - | // reads the first few bytes of each file in the cwd's session dir. | 2637 | + | // cwd-scoped picker paths. Explicit UUID lookup stays strict so it |
| 2638 | + | // can report metadata failures instead of turning them into misses. | |||
| 2600 | sessionsRoot := agentSessionsRoot(ZutHome(), args) | 2639 | sessionsRoot := agentSessionsRoot(ZutHome(), args) | ||
| 2601 | - | core.PruneEmptySessions(sessionsRoot, args.CWD) | 2640 | + | if args.ResumeSessionID == "" { |
| 2641 | + | core.PruneEmptySessions(sessionsRoot, args.CWD) | |||
| 2642 | + | } | |||
| 2602 | var ( | 2643 | var ( | ||
| 2603 | s *core.Session | 2644 | s *core.Session | ||
| 2604 | msgs []provider.Message | 2645 | msgs []provider.Message | ||
| @@ -2625,6 +2666,17 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 2625 | s, msgs, err = core.OpenSession(latest) | 2666 | s, msgs, err = core.OpenSession(latest) | ||
| 2626 | } | 2667 | } | ||
| 2627 | case args.Resume: | 2668 | case args.Resume: | ||
| 2669 | + | if args.ResumeSessionID != "" { | |||
| 2670 | + | picked, lookupErr := core.FindManagedSessionByID(ZutHome(), args.ResumeSessionID) | |||
| 2671 | + | if lookupErr != nil { | |||
| 2672 | + | return nil, lookupErr | |||
| 2673 | + | } | |||
| 2674 | + | if picked == "" { | |||
| 2675 | + | return nil, fmt.Errorf("session %q not found", args.ResumeSessionID) | |||
| 2676 | + | } | |||
| 2677 | + | s, msgs, err = core.OpenSession(picked) | |||
| 2678 | + | break | |||
| 2679 | + | } | |||
| 2628 | picked, perr := pickSession(sessionsRoot, args.CWD) | 2680 | picked, perr := pickSession(sessionsRoot, args.CWD) | ||
| 2629 | if perr != nil { | 2681 | if perr != nil { | ||
| 2630 | return nil, perr | 2682 | return nil, perr | ||
M packages/agent/cli_session_test.go +170 -0
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 9 | 11 | "sync" | |
| 10 | 12 | "testing" | |
| 11 | 13 | "time" | |
| @@ -13,8 +15,176 @@ import ( | |||
| 13 | 15 | "github.com/bnema/zut/packages/agent/modes" | |
| 14 | 16 | "github.com/bnema/zut/packages/core" | |
| 15 | 17 | "github.com/bnema/zut/packages/provider" | |
| 18 | + | "github.com/google/uuid" | |
| 16 | 19 | ) | |
| 17 | 20 | ||
| 21 | + | func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) { | |
| 22 | + | root := t.TempDir() | |
| 23 | + | cwd := t.TempDir() | |
| 24 | + | otherCWD := t.TempDir() | |
| 25 | + | t.Setenv("ZUT_HOME", root) | |
| 26 | + | ||
| 27 | + | session, err := core.NewSession(root, cwd, "provider", "model", "test") | |
| 28 | + | if err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | if err := session.AppendMessage(provider.Message{ | |
| 32 | + | Role: provider.RoleUser, | |
| 33 | + | Content: []provider.Content{provider.TextBlock{Text: "resume me"}}, | |
| 34 | + | }); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | id := session.ID | |
| 38 | + | if err := session.Close(); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | ag := core.NewAgent(nil, "model", "", nil) | |
| 43 | + | got, err := openOrCreateSession(Args{ | |
| 44 | + | CWD: otherCWD, | |
| 45 | + | Resume: true, | |
| 46 | + | ResumeSessionID: id, | |
| 47 | + | }, Resolved{Provider: "provider", Model: "model"}, ag, "test") | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | if got == nil || got.ID != id || got.Meta.CWD != cwd { | |
| 52 | + | t.Fatalf("resumed session = %#v, want id=%q cwd=%q", got, id, cwd) | |
| 53 | + | } | |
| 54 | + | if got := firstMessageText(ag.Messages()); got != "resume me" { | |
| 55 | + | t.Fatalf("resumed message = %q, want resume me", got) | |
| 56 | + | } | |
| 57 | + | if err := got.Close(); err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | namedRoot := filepath.Join(root, "sessions", "agents", "demo") | |
| 62 | + | named, err := core.NewSession(namedRoot, cwd, "provider", "model", "test") | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | if err := named.AppendMessage(provider.Message{ | |
| 67 | + | Role: provider.RoleUser, | |
| 68 | + | Content: []provider.Content{provider.TextBlock{Text: "named resume me"}}, | |
| 69 | + | }); err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | namedID := named.ID | |
| 73 | + | if err := named.Close(); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | namedAgent := core.NewAgent(nil, "model", "", nil) | |
| 78 | + | namedGot, err := openOrCreateSession(Args{ | |
| 79 | + | CWD: otherCWD, | |
| 80 | + | Resume: true, | |
| 81 | + | ResumeSessionID: namedID, | |
| 82 | + | }, Resolved{Provider: "provider", Model: "model"}, namedAgent, "test") | |
| 83 | + | if err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | if namedGot == nil || namedGot.ID != namedID || namedGot.Path != named.Path { | |
| 87 | + | t.Fatalf("named resumed session = %#v, want path %q", namedGot, named.Path) | |
| 88 | + | } | |
| 89 | + | if got := firstMessageText(namedAgent.Messages()); got != "named resume me" { | |
| 90 | + | t.Fatalf("named resumed message = %q, want named resume me", got) | |
| 91 | + | } | |
| 92 | + | if err := namedGot.Close(); err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) { | |
| 98 | + | root := t.TempDir() | |
| 99 | + | cwd := t.TempDir() | |
| 100 | + | ||
| 101 | + | empty, err := core.NewSession(root, cwd, "provider", "model", "test") | |
| 102 | + | if err != nil { | |
| 103 | + | t.Fatal(err) | |
| 104 | + | } | |
| 105 | + | if err := empty.Close(); err != nil { | |
| 106 | + | t.Fatal(err) | |
| 107 | + | } | |
| 108 | + | if _, err := os.Stat(empty.Path); !os.IsNotExist(err) { | |
| 109 | + | t.Fatalf("empty session stat error = %v, want deleted", err) | |
| 110 | + | } | |
| 111 | + | if got := resumableSessionID(root, empty); got != "" { | |
| 112 | + | t.Fatalf("empty session hint ID = %q, want empty", got) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | arbitraryPath := filepath.Join(t.TempDir(), "session.jsonl") | |
| 116 | + | arbitrary, err := core.NewSessionAtPath(arbitraryPath, cwd, "provider", "model", "test") | |
| 117 | + | if err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if err := arbitrary.AppendMessage(provider.Message{ | |
| 121 | + | Role: provider.RoleUser, | |
| 122 | + | Content: []provider.Content{provider.TextBlock{Text: "arbitrary"}}, | |
| 123 | + | }); err != nil { | |
| 124 | + | t.Fatal(err) | |
| 125 | + | } | |
| 126 | + | if err := arbitrary.Close(); err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 129 | + | if got := resumableSessionID(root, arbitrary); got != "" { | |
| 130 | + | t.Fatalf("arbitrary session hint ID = %q, want empty", got) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) { | |
| 135 | + | root := t.TempDir() | |
| 136 | + | cwd := t.TempDir() | |
| 137 | + | t.Setenv("ZUT_HOME", root) | |
| 138 | + | id := uuid.NewString() | |
| 139 | + | ||
| 140 | + | _, err := openOrCreateSession(Args{ | |
| 141 | + | CWD: cwd, | |
| 142 | + | Resume: true, | |
| 143 | + | ResumeSessionID: id, | |
| 144 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |
| 145 | + | if err == nil { | |
| 146 | + | t.Fatal("unknown UUID resume unexpectedly succeeded") | |
| 147 | + | } | |
| 148 | + | if !strings.Contains(err.Error(), id) { | |
| 149 | + | t.Fatalf("unknown UUID error = %v, want session ID", err) | |
| 150 | + | } | |
| 151 | + | if sessions := core.ListSessions(root, cwd); len(sessions) != 0 { | |
| 152 | + | t.Fatalf("unknown UUID created sessions: %v", sessions) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) { | |
| 157 | + | root := t.TempDir() | |
| 158 | + | cwd := t.TempDir() | |
| 159 | + | t.Setenv("ZUT_HOME", root) | |
| 160 | + | if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil { | |
| 164 | + | t.Fatal(err) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | _, err := openOrCreateSession(Args{ | |
| 168 | + | CWD: cwd, | |
| 169 | + | Resume: true, | |
| 170 | + | ResumeSessionID: uuid.NewString(), | |
| 171 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |
| 172 | + | if err == nil { | |
| 173 | + | t.Fatal("malformed UUID lookup unexpectedly succeeded") | |
| 174 | + | } | |
| 175 | + | if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") { | |
| 176 | + | t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestResumeSessionHint(t *testing.T) { | |
| 181 | + | id := uuid.NewString() | |
| 182 | + | want := "Resume this session with: zut --resume " + id + "\n" | |
| 183 | + | if got := resumeSessionHint(id); got != want { | |
| 184 | + | t.Fatalf("resume hint = %q, want %q", got, want) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 18 | 188 | func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) { | |
| 19 | 189 | msgs := make([]provider.Message, 0, 101) | |
| 20 | 190 | msgs = append(msgs, provider.Message{ | |
| @@ -6,6 +6,8 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 9 | "sync" | 11 | "sync" | ||
| 10 | "testing" | 12 | "testing" | ||
| 11 | "time" | 13 | "time" | ||
| @@ -13,8 +15,176 @@ import ( | |||||
| 13 | "github.com/bnema/zut/packages/agent/modes" | 15 | "github.com/bnema/zut/packages/agent/modes" | ||
| 14 | "github.com/bnema/zut/packages/core" | 16 | "github.com/bnema/zut/packages/core" | ||
| 15 | "github.com/bnema/zut/packages/provider" | 17 | "github.com/bnema/zut/packages/provider" | ||
| 18 | + | "github.com/google/uuid" | |||
| 16 | ) | 19 | ) | ||
| 17 | 20 | ||||
| 21 | + | func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) { | |||
| 22 | + | root := t.TempDir() | |||
| 23 | + | cwd := t.TempDir() | |||
| 24 | + | otherCWD := t.TempDir() | |||
| 25 | + | t.Setenv("ZUT_HOME", root) | |||
| 26 | + | ||||
| 27 | + | session, err := core.NewSession(root, cwd, "provider", "model", "test") | |||
| 28 | + | if err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | if err := session.AppendMessage(provider.Message{ | |||
| 32 | + | Role: provider.RoleUser, | |||
| 33 | + | Content: []provider.Content{provider.TextBlock{Text: "resume me"}}, | |||
| 34 | + | }); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | id := session.ID | |||
| 38 | + | if err := session.Close(); err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | ag := core.NewAgent(nil, "model", "", nil) | |||
| 43 | + | got, err := openOrCreateSession(Args{ | |||
| 44 | + | CWD: otherCWD, | |||
| 45 | + | Resume: true, | |||
| 46 | + | ResumeSessionID: id, | |||
| 47 | + | }, Resolved{Provider: "provider", Model: "model"}, ag, "test") | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | if got == nil || got.ID != id || got.Meta.CWD != cwd { | |||
| 52 | + | t.Fatalf("resumed session = %#v, want id=%q cwd=%q", got, id, cwd) | |||
| 53 | + | } | |||
| 54 | + | if got := firstMessageText(ag.Messages()); got != "resume me" { | |||
| 55 | + | t.Fatalf("resumed message = %q, want resume me", got) | |||
| 56 | + | } | |||
| 57 | + | if err := got.Close(); err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | namedRoot := filepath.Join(root, "sessions", "agents", "demo") | |||
| 62 | + | named, err := core.NewSession(namedRoot, cwd, "provider", "model", "test") | |||
| 63 | + | if err != nil { | |||
| 64 | + | t.Fatal(err) | |||
| 65 | + | } | |||
| 66 | + | if err := named.AppendMessage(provider.Message{ | |||
| 67 | + | Role: provider.RoleUser, | |||
| 68 | + | Content: []provider.Content{provider.TextBlock{Text: "named resume me"}}, | |||
| 69 | + | }); err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | namedID := named.ID | |||
| 73 | + | if err := named.Close(); err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | namedAgent := core.NewAgent(nil, "model", "", nil) | |||
| 78 | + | namedGot, err := openOrCreateSession(Args{ | |||
| 79 | + | CWD: otherCWD, | |||
| 80 | + | Resume: true, | |||
| 81 | + | ResumeSessionID: namedID, | |||
| 82 | + | }, Resolved{Provider: "provider", Model: "model"}, namedAgent, "test") | |||
| 83 | + | if err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | if namedGot == nil || namedGot.ID != namedID || namedGot.Path != named.Path { | |||
| 87 | + | t.Fatalf("named resumed session = %#v, want path %q", namedGot, named.Path) | |||
| 88 | + | } | |||
| 89 | + | if got := firstMessageText(namedAgent.Messages()); got != "named resume me" { | |||
| 90 | + | t.Fatalf("named resumed message = %q, want named resume me", got) | |||
| 91 | + | } | |||
| 92 | + | if err := namedGot.Close(); err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) { | |||
| 98 | + | root := t.TempDir() | |||
| 99 | + | cwd := t.TempDir() | |||
| 100 | + | ||||
| 101 | + | empty, err := core.NewSession(root, cwd, "provider", "model", "test") | |||
| 102 | + | if err != nil { | |||
| 103 | + | t.Fatal(err) | |||
| 104 | + | } | |||
| 105 | + | if err := empty.Close(); err != nil { | |||
| 106 | + | t.Fatal(err) | |||
| 107 | + | } | |||
| 108 | + | if _, err := os.Stat(empty.Path); !os.IsNotExist(err) { | |||
| 109 | + | t.Fatalf("empty session stat error = %v, want deleted", err) | |||
| 110 | + | } | |||
| 111 | + | if got := resumableSessionID(root, empty); got != "" { | |||
| 112 | + | t.Fatalf("empty session hint ID = %q, want empty", got) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | arbitraryPath := filepath.Join(t.TempDir(), "session.jsonl") | |||
| 116 | + | arbitrary, err := core.NewSessionAtPath(arbitraryPath, cwd, "provider", "model", "test") | |||
| 117 | + | if err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | if err := arbitrary.AppendMessage(provider.Message{ | |||
| 121 | + | Role: provider.RoleUser, | |||
| 122 | + | Content: []provider.Content{provider.TextBlock{Text: "arbitrary"}}, | |||
| 123 | + | }); err != nil { | |||
| 124 | + | t.Fatal(err) | |||
| 125 | + | } | |||
| 126 | + | if err := arbitrary.Close(); err != nil { | |||
| 127 | + | t.Fatal(err) | |||
| 128 | + | } | |||
| 129 | + | if got := resumableSessionID(root, arbitrary); got != "" { | |||
| 130 | + | t.Fatalf("arbitrary session hint ID = %q, want empty", got) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) { | |||
| 135 | + | root := t.TempDir() | |||
| 136 | + | cwd := t.TempDir() | |||
| 137 | + | t.Setenv("ZUT_HOME", root) | |||
| 138 | + | id := uuid.NewString() | |||
| 139 | + | ||||
| 140 | + | _, err := openOrCreateSession(Args{ | |||
| 141 | + | CWD: cwd, | |||
| 142 | + | Resume: true, | |||
| 143 | + | ResumeSessionID: id, | |||
| 144 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |||
| 145 | + | if err == nil { | |||
| 146 | + | t.Fatal("unknown UUID resume unexpectedly succeeded") | |||
| 147 | + | } | |||
| 148 | + | if !strings.Contains(err.Error(), id) { | |||
| 149 | + | t.Fatalf("unknown UUID error = %v, want session ID", err) | |||
| 150 | + | } | |||
| 151 | + | if sessions := core.ListSessions(root, cwd); len(sessions) != 0 { | |||
| 152 | + | t.Fatalf("unknown UUID created sessions: %v", sessions) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) { | |||
| 157 | + | root := t.TempDir() | |||
| 158 | + | cwd := t.TempDir() | |||
| 159 | + | t.Setenv("ZUT_HOME", root) | |||
| 160 | + | if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil { | |||
| 161 | + | t.Fatal(err) | |||
| 162 | + | } | |||
| 163 | + | if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | _, err := openOrCreateSession(Args{ | |||
| 168 | + | CWD: cwd, | |||
| 169 | + | Resume: true, | |||
| 170 | + | ResumeSessionID: uuid.NewString(), | |||
| 171 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |||
| 172 | + | if err == nil { | |||
| 173 | + | t.Fatal("malformed UUID lookup unexpectedly succeeded") | |||
| 174 | + | } | |||
| 175 | + | if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") { | |||
| 176 | + | t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestResumeSessionHint(t *testing.T) { | |||
| 181 | + | id := uuid.NewString() | |||
| 182 | + | want := "Resume this session with: zut --resume " + id + "\n" | |||
| 183 | + | if got := resumeSessionHint(id); got != want { | |||
| 184 | + | t.Fatalf("resume hint = %q, want %q", got, want) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 18 | func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) { | 188 | func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) { | ||
| 19 | msgs := make([]provider.Message, 0, 101) | 189 | msgs := make([]provider.Message, 0, 101) | ||
| 20 | msgs = append(msgs, provider.Message{ | 190 | msgs = append(msgs, provider.Message{ | ||
M packages/agent/modes/double_escape_test.go +17 -0
| @@ -10,6 +10,23 @@ import ( | |||
| 10 | 10 | "github.com/bnema/zut/packages/tui" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | func TestInteractiveCtrlCExitMarker(t *testing.T) { | |
| 14 | + | i := NewInteractive(InteractiveConfig{}) | |
| 15 | + | key := tui.Key{Kind: tui.KeyCtrlC} | |
| 16 | + | if i.handleKey(context.Background(), key) { | |
| 17 | + | t.Fatal("first Ctrl+C exited interactive mode") | |
| 18 | + | } | |
| 19 | + | if i.ExitedViaCtrlC() { | |
| 20 | + | t.Fatal("first Ctrl+C marked an exit") | |
| 21 | + | } | |
| 22 | + | if !i.handleKey(context.Background(), key) { | |
| 23 | + | t.Fatal("second Ctrl+C did not exit interactive mode") | |
| 24 | + | } | |
| 25 | + | if !i.ExitedViaCtrlC() { | |
| 26 | + | t.Fatal("Ctrl+C exit was not marked") | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 13 | 30 | func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) { | |
| 14 | 31 | base := time.Unix(100, 0) | |
| 15 | 32 | var tracker doubleEscapeTracker | |
| @@ -10,6 +10,23 @@ import ( | |||||
| 10 | "github.com/bnema/zut/packages/tui" | 10 | "github.com/bnema/zut/packages/tui" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | func TestInteractiveCtrlCExitMarker(t *testing.T) { | |||
| 14 | + | i := NewInteractive(InteractiveConfig{}) | |||
| 15 | + | key := tui.Key{Kind: tui.KeyCtrlC} | |||
| 16 | + | if i.handleKey(context.Background(), key) { | |||
| 17 | + | t.Fatal("first Ctrl+C exited interactive mode") | |||
| 18 | + | } | |||
| 19 | + | if i.ExitedViaCtrlC() { | |||
| 20 | + | t.Fatal("first Ctrl+C marked an exit") | |||
| 21 | + | } | |||
| 22 | + | if !i.handleKey(context.Background(), key) { | |||
| 23 | + | t.Fatal("second Ctrl+C did not exit interactive mode") | |||
| 24 | + | } | |||
| 25 | + | if !i.ExitedViaCtrlC() { | |||
| 26 | + | t.Fatal("Ctrl+C exit was not marked") | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 13 | func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) { | 30 | func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) { | ||
| 14 | base := time.Unix(100, 0) | 31 | base := time.Unix(100, 0) | ||
| 15 | var tracker doubleEscapeTracker | 32 | var tracker doubleEscapeTracker | ||
M packages/agent/modes/interactive.go +19 -0
| @@ -664,6 +664,7 @@ type Interactive struct { | |||
| 664 | 664 | cumUsage provider.Usage | |
| 665 | 665 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 666 | 666 | busy bool | |
| 667 | + | ctrlCExit bool | |
| 667 | 668 | activity agentActivity | |
| 668 | 669 | subagentActivityActive bool | |
| 669 | 670 | pendingIdleWork []func() | |
| @@ -1009,6 +1010,21 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1009 | 1010 | return i | |
| 1010 | 1011 | } | |
| 1011 | 1012 | ||
| 1013 | + | // ExitedViaCtrlC reports whether the last Run returned because the user | |
| 1014 | + | // deliberately exited with Ctrl+C. | |
| 1015 | + | func (i *Interactive) ExitedViaCtrlC() bool { | |
| 1016 | + | i.mu.Lock() | |
| 1017 | + | defer i.mu.Unlock() | |
| 1018 | + | return i.ctrlCExit | |
| 1019 | + | } | |
| 1020 | + | ||
| 1021 | + | // markCtrlCExit records that the current Run is ending via Ctrl+C. | |
| 1022 | + | func (i *Interactive) markCtrlCExit() { | |
| 1023 | + | i.mu.Lock() | |
| 1024 | + | i.ctrlCExit = true | |
| 1025 | + | i.mu.Unlock() | |
| 1026 | + | } | |
| 1027 | + | ||
| 1012 | 1028 | // Run blocks until the user quits. | |
| 1013 | 1029 | func (i *Interactive) Run(ctx context.Context) error { | |
| 1014 | 1030 | i.runCtx = ctx | |
| @@ -3183,6 +3199,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3183 | 3199 | loadingSession := i.sessionLoading | |
| 3184 | 3200 | i.mu.Unlock() | |
| 3185 | 3201 | if loadingSession { | |
| 3202 | + | i.markCtrlCExit() | |
| 3186 | 3203 | return true | |
| 3187 | 3204 | } | |
| 3188 | 3205 | // While busy: do NOT cancel the turn. ctrl+c during a | |
| @@ -3195,6 +3212,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3195 | 3212 | // ctrlCExitWindow quits. | |
| 3196 | 3213 | if i.busy { | |
| 3197 | 3214 | if i.ctrlCExitArmed() { | |
| 3215 | + | i.markCtrlCExit() | |
| 3198 | 3216 | return true | |
| 3199 | 3217 | } | |
| 3200 | 3218 | i.mu.Lock() | |
| @@ -3230,6 +3248,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3230 | 3248 | return false | |
| 3231 | 3249 | } | |
| 3232 | 3250 | if i.ctrlCExitArmed() { | |
| 3251 | + | i.markCtrlCExit() | |
| 3233 | 3252 | return true | |
| 3234 | 3253 | } | |
| 3235 | 3254 | i.mu.Lock() | |
| @@ -664,6 +664,7 @@ type Interactive struct { | |||||
| 664 | cumUsage provider.Usage | 664 | cumUsage provider.Usage | ||
| 665 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 665 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 666 | busy bool | 666 | busy bool | ||
| 667 | + | ctrlCExit bool | |||
| 667 | activity agentActivity | 668 | activity agentActivity | ||
| 668 | subagentActivityActive bool | 669 | subagentActivityActive bool | ||
| 669 | pendingIdleWork []func() | 670 | pendingIdleWork []func() | ||
| @@ -1009,6 +1010,21 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 1009 | return i | 1010 | return i | ||
| 1010 | } | 1011 | } | ||
| 1011 | 1012 | ||||
| 1013 | + | // ExitedViaCtrlC reports whether the last Run returned because the user | |||
| 1014 | + | // deliberately exited with Ctrl+C. | |||
| 1015 | + | func (i *Interactive) ExitedViaCtrlC() bool { | |||
| 1016 | + | i.mu.Lock() | |||
| 1017 | + | defer i.mu.Unlock() | |||
| 1018 | + | return i.ctrlCExit | |||
| 1019 | + | } | |||
| 1020 | + | ||||
| 1021 | + | // markCtrlCExit records that the current Run is ending via Ctrl+C. | |||
| 1022 | + | func (i *Interactive) markCtrlCExit() { | |||
| 1023 | + | i.mu.Lock() | |||
| 1024 | + | i.ctrlCExit = true | |||
| 1025 | + | i.mu.Unlock() | |||
| 1026 | + | } | |||
| 1027 | + | ||||
| 1012 | // Run blocks until the user quits. | 1028 | // Run blocks until the user quits. | ||
| 1013 | func (i *Interactive) Run(ctx context.Context) error { | 1029 | func (i *Interactive) Run(ctx context.Context) error { | ||
| 1014 | i.runCtx = ctx | 1030 | i.runCtx = ctx | ||
| @@ -3183,6 +3199,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3183 | loadingSession := i.sessionLoading | 3199 | loadingSession := i.sessionLoading | ||
| 3184 | i.mu.Unlock() | 3200 | i.mu.Unlock() | ||
| 3185 | if loadingSession { | 3201 | if loadingSession { | ||
| 3202 | + | i.markCtrlCExit() | |||
| 3186 | return true | 3203 | return true | ||
| 3187 | } | 3204 | } | ||
| 3188 | // While busy: do NOT cancel the turn. ctrl+c during a | 3205 | // While busy: do NOT cancel the turn. ctrl+c during a | ||
| @@ -3195,6 +3212,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3195 | // ctrlCExitWindow quits. | 3212 | // ctrlCExitWindow quits. | ||
| 3196 | if i.busy { | 3213 | if i.busy { | ||
| 3197 | if i.ctrlCExitArmed() { | 3214 | if i.ctrlCExitArmed() { | ||
| 3215 | + | i.markCtrlCExit() | |||
| 3198 | return true | 3216 | return true | ||
| 3199 | } | 3217 | } | ||
| 3200 | i.mu.Lock() | 3218 | i.mu.Lock() | ||
| @@ -3230,6 +3248,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3230 | return false | 3248 | return false | ||
| 3231 | } | 3249 | } | ||
| 3232 | if i.ctrlCExitArmed() { | 3250 | if i.ctrlCExitArmed() { | ||
| 3251 | + | i.markCtrlCExit() | |||
| 3233 | return true | 3252 | return true | ||
| 3234 | } | 3253 | } | ||
| 3235 | i.mu.Lock() | 3254 | i.mu.Lock() | ||
M packages/core/session_portable.go +88 -0
| @@ -882,6 +882,94 @@ func FindSessionByID(root, cwd, id string) string { | |||
| 882 | 882 | return "" | |
| 883 | 883 | } | |
| 884 | 884 | ||
| 885 | + | // FindManagedSessionByID looks up a session UUID across zut's managed | |
| 886 | + | // session stores, independent of cwd. A nil error and empty path mean no | |
| 887 | + | // matching session was found. Read, storage, metadata, and duplicate-ID | |
| 888 | + | // failures are returned to the caller instead of being reported as a miss. | |
| 889 | + | func FindManagedSessionByID(root, id string) (string, error) { | |
| 890 | + | if strings.TrimSpace(root) == "" { | |
| 891 | + | return "", errors.New("find managed session: root is empty") | |
| 892 | + | } | |
| 893 | + | if strings.TrimSpace(id) == "" { | |
| 894 | + | return "", errors.New("find managed session: id is empty") | |
| 895 | + | } | |
| 896 | + | ||
| 897 | + | stores := []string{root} | |
| 898 | + | agentsDir := filepath.Join(root, "sessions", "agents") | |
| 899 | + | agents, err := os.ReadDir(agentsDir) | |
| 900 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 901 | + | return "", fmt.Errorf("find managed session: read agent stores: %w", err) | |
| 902 | + | } | |
| 903 | + | if err == nil { | |
| 904 | + | for _, entry := range agents { | |
| 905 | + | if entry.IsDir() { | |
| 906 | + | stores = append(stores, filepath.Join(agentsDir, entry.Name())) | |
| 907 | + | } | |
| 908 | + | } | |
| 909 | + | } | |
| 910 | + | ||
| 911 | + | var matches []string | |
| 912 | + | for _, store := range stores { | |
| 913 | + | if err := collectManagedSessionMatches(store, id, &matches); err != nil { | |
| 914 | + | return "", err | |
| 915 | + | } | |
| 916 | + | } | |
| 917 | + | if len(matches) == 0 { | |
| 918 | + | return "", nil | |
| 919 | + | } | |
| 920 | + | if len(matches) > 1 { | |
| 921 | + | return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id) | |
| 922 | + | } | |
| 923 | + | return matches[0], nil | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | func collectManagedSessionMatches(root, id string, matches *[]string) error { | |
| 927 | + | dir := filepath.Join(root, "sessions") | |
| 928 | + | entries, err := os.ReadDir(dir) | |
| 929 | + | if errors.Is(err, os.ErrNotExist) { | |
| 930 | + | return nil | |
| 931 | + | } | |
| 932 | + | if err != nil { | |
| 933 | + | return fmt.Errorf("find managed session: read %q: %w", dir, err) | |
| 934 | + | } | |
| 935 | + | for _, bucket := range entries { | |
| 936 | + | if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) { | |
| 937 | + | continue | |
| 938 | + | } | |
| 939 | + | bucketPath := filepath.Join(dir, bucket.Name()) | |
| 940 | + | files, err := os.ReadDir(bucketPath) | |
| 941 | + | if err != nil { | |
| 942 | + | return fmt.Errorf("find managed session: read %q: %w", bucketPath, err) | |
| 943 | + | } | |
| 944 | + | for _, file := range files { | |
| 945 | + | if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") { | |
| 946 | + | continue | |
| 947 | + | } | |
| 948 | + | path := filepath.Join(bucketPath, file.Name()) | |
| 949 | + | meta, err := scanSessionMeta(path) | |
| 950 | + | if err != nil { | |
| 951 | + | return fmt.Errorf("find managed session: read metadata %q: %w", path, err) | |
| 952 | + | } | |
| 953 | + | if meta.ID == id { | |
| 954 | + | *matches = append(*matches, path) | |
| 955 | + | } | |
| 956 | + | } | |
| 957 | + | } | |
| 958 | + | return nil | |
| 959 | + | } | |
| 960 | + | ||
| 961 | + | func isSessionBucketName(name string) bool { | |
| 962 | + | if len(name) != 16 { | |
| 963 | + | return false | |
| 964 | + | } | |
| 965 | + | for _, r := range name { | |
| 966 | + | if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { | |
| 967 | + | return false | |
| 968 | + | } | |
| 969 | + | } | |
| 970 | + | return true | |
| 971 | + | } | |
| 972 | + | ||
| 885 | 973 | // firstUserPrompt scans forward from the current source position | |
| 886 | 974 | // looking for the first user-role message and returns its text. | |
| 887 | 975 | // Used to build a humane export filename. Uses Reader instead of | |
| @@ -882,6 +882,94 @@ func FindSessionByID(root, cwd, id string) string { | |||||
| 882 | return "" | 882 | return "" | ||
| 883 | } | 883 | } | ||
| 884 | 884 | ||||
| 885 | + | // FindManagedSessionByID looks up a session UUID across zut's managed | |||
| 886 | + | // session stores, independent of cwd. A nil error and empty path mean no | |||
| 887 | + | // matching session was found. Read, storage, metadata, and duplicate-ID | |||
| 888 | + | // failures are returned to the caller instead of being reported as a miss. | |||
| 889 | + | func FindManagedSessionByID(root, id string) (string, error) { | |||
| 890 | + | if strings.TrimSpace(root) == "" { | |||
| 891 | + | return "", errors.New("find managed session: root is empty") | |||
| 892 | + | } | |||
| 893 | + | if strings.TrimSpace(id) == "" { | |||
| 894 | + | return "", errors.New("find managed session: id is empty") | |||
| 895 | + | } | |||
| 896 | + | ||||
| 897 | + | stores := []string{root} | |||
| 898 | + | agentsDir := filepath.Join(root, "sessions", "agents") | |||
| 899 | + | agents, err := os.ReadDir(agentsDir) | |||
| 900 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 901 | + | return "", fmt.Errorf("find managed session: read agent stores: %w", err) | |||
| 902 | + | } | |||
| 903 | + | if err == nil { | |||
| 904 | + | for _, entry := range agents { | |||
| 905 | + | if entry.IsDir() { | |||
| 906 | + | stores = append(stores, filepath.Join(agentsDir, entry.Name())) | |||
| 907 | + | } | |||
| 908 | + | } | |||
| 909 | + | } | |||
| 910 | + | ||||
| 911 | + | var matches []string | |||
| 912 | + | for _, store := range stores { | |||
| 913 | + | if err := collectManagedSessionMatches(store, id, &matches); err != nil { | |||
| 914 | + | return "", err | |||
| 915 | + | } | |||
| 916 | + | } | |||
| 917 | + | if len(matches) == 0 { | |||
| 918 | + | return "", nil | |||
| 919 | + | } | |||
| 920 | + | if len(matches) > 1 { | |||
| 921 | + | return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id) | |||
| 922 | + | } | |||
| 923 | + | return matches[0], nil | |||
| 924 | + | } | |||
| 925 | + | ||||
| 926 | + | func collectManagedSessionMatches(root, id string, matches *[]string) error { | |||
| 927 | + | dir := filepath.Join(root, "sessions") | |||
| 928 | + | entries, err := os.ReadDir(dir) | |||
| 929 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 930 | + | return nil | |||
| 931 | + | } | |||
| 932 | + | if err != nil { | |||
| 933 | + | return fmt.Errorf("find managed session: read %q: %w", dir, err) | |||
| 934 | + | } | |||
| 935 | + | for _, bucket := range entries { | |||
| 936 | + | if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) { | |||
| 937 | + | continue | |||
| 938 | + | } | |||
| 939 | + | bucketPath := filepath.Join(dir, bucket.Name()) | |||
| 940 | + | files, err := os.ReadDir(bucketPath) | |||
| 941 | + | if err != nil { | |||
| 942 | + | return fmt.Errorf("find managed session: read %q: %w", bucketPath, err) | |||
| 943 | + | } | |||
| 944 | + | for _, file := range files { | |||
| 945 | + | if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") { | |||
| 946 | + | continue | |||
| 947 | + | } | |||
| 948 | + | path := filepath.Join(bucketPath, file.Name()) | |||
| 949 | + | meta, err := scanSessionMeta(path) | |||
| 950 | + | if err != nil { | |||
| 951 | + | return fmt.Errorf("find managed session: read metadata %q: %w", path, err) | |||
| 952 | + | } | |||
| 953 | + | if meta.ID == id { | |||
| 954 | + | *matches = append(*matches, path) | |||
| 955 | + | } | |||
| 956 | + | } | |||
| 957 | + | } | |||
| 958 | + | return nil | |||
| 959 | + | } | |||
| 960 | + | ||||
| 961 | + | func isSessionBucketName(name string) bool { | |||
| 962 | + | if len(name) != 16 { | |||
| 963 | + | return false | |||
| 964 | + | } | |||
| 965 | + | for _, r := range name { | |||
| 966 | + | if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { | |||
| 967 | + | return false | |||
| 968 | + | } | |||
| 969 | + | } | |||
| 970 | + | return true | |||
| 971 | + | } | |||
| 972 | + | ||||
| 885 | // firstUserPrompt scans forward from the current source position | 973 | // firstUserPrompt scans forward from the current source position | ||
| 886 | // looking for the first user-role message and returns its text. | 974 | // looking for the first user-role message and returns its text. | ||
| 887 | // Used to build a humane export filename. Uses Reader instead of | 975 | // Used to build a humane export filename. Uses Reader instead of | ||
M packages/core/session_portable_test.go +36 -0
| @@ -10,6 +10,42 @@ import ( | |||
| 10 | 10 | "github.com/bnema/zut/packages/provider" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) { | |
| 14 | + | root := t.TempDir() | |
| 15 | + | cwd := t.TempDir() | |
| 16 | + | session, err := NewSession(root, cwd, "provider", "model", "test") | |
| 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: "managed"}}, | |
| 23 | + | }); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | if err := session.Close(); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | got, err := FindManagedSessionByID(root, session.ID) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatalf("FindManagedSessionByID: %v", err) | |
| 33 | + | } | |
| 34 | + | if got != session.Path { | |
| 35 | + | t.Fatalf("managed session path = %q, want %q", got, session.Path) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl") | |
| 39 | + | if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | if _, err := FindManagedSessionByID(root, "missing"); err == nil { | |
| 43 | + | t.Fatal("malformed managed metadata was reported as not found") | |
| 44 | + | } else if !strings.Contains(err.Error(), "metadata") { | |
| 45 | + | t.Fatalf("malformed metadata error = %v, want metadata context", err) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 13 | 49 | // TestSessionExportImportRoundTrip writes a few messages to a live | |
| 14 | 50 | // session, exports it, imports the export under a different cwd, | |
| 15 | 51 | // and verifies OpenSession on the imported file yields the same | |
| @@ -10,6 +10,42 @@ import ( | |||||
| 10 | "github.com/bnema/zut/packages/provider" | 10 | "github.com/bnema/zut/packages/provider" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) { | |||
| 14 | + | root := t.TempDir() | |||
| 15 | + | cwd := t.TempDir() | |||
| 16 | + | session, err := NewSession(root, cwd, "provider", "model", "test") | |||
| 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: "managed"}}, | |||
| 23 | + | }); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | if err := session.Close(); err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | got, err := FindManagedSessionByID(root, session.ID) | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatalf("FindManagedSessionByID: %v", err) | |||
| 33 | + | } | |||
| 34 | + | if got != session.Path { | |||
| 35 | + | t.Fatalf("managed session path = %q, want %q", got, session.Path) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl") | |||
| 39 | + | if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | if _, err := FindManagedSessionByID(root, "missing"); err == nil { | |||
| 43 | + | t.Fatal("malformed managed metadata was reported as not found") | |||
| 44 | + | } else if !strings.Contains(err.Error(), "metadata") { | |||
| 45 | + | t.Fatalf("malformed metadata error = %v, want metadata context", err) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 13 | // TestSessionExportImportRoundTrip writes a few messages to a live | 49 | // TestSessionExportImportRoundTrip writes a few messages to a live | ||
| 14 | // session, exports it, imports the export under a different cwd, | 50 | // session, exports it, imports the export under a different cwd, | ||
| 15 | // and verifies OpenSession on the imported file yields the same | 51 | // and verifies OpenSession on the imported file yields the same | ||