Merge pull request #77 from bnema/feat/resume-session-id
feat(session): resume sessions by UUID
10 files · +527 -16
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 | |
| @@ -200,6 +202,12 @@ func ParseArgs(in []string) (Args, error) { | |||
| 200 | 202 | a.Continue = true | |
| 201 | 203 | case "-r", "--resume": | |
| 202 | 204 | a.Resume = true | |
| 205 | + | if i+1 < len(in) { | |
| 206 | + | if id, err := uuid.Parse(in[i+1]); err == nil { | |
| 207 | + | a.ResumeSessionID = id.String() | |
| 208 | + | i++ | |
| 209 | + | } | |
| 210 | + | } | |
| 203 | 211 | case "--no-session": | |
| 204 | 212 | a.NoSess = true | |
| 205 | 213 | case "--no-tools": | |
| @@ -535,7 +543,7 @@ func PrintHelp(version string) { | |||
| 535 | 543 | row{"--system-prompt TEXT", "replace the default system prompt"}, | |
| 536 | 544 | row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"}, | |
| 537 | 545 | row{"-c, --continue", "continue the most recent session for this cwd"}, | |
| 538 | - | row{"-r, --resume", "pick a session to resume"}, | |
| 546 | + | row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"}, | |
| 539 | 547 | row{"--session PATH", "resume a specific session file"}, | |
| 540 | 548 | row{"--no-session", "do not read or write a session file"}, | |
| 541 | 549 | ) | |
| @@ -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 | ||
| @@ -200,6 +202,12 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 200 | a.Continue = true | 202 | a.Continue = true | ||
| 201 | case "-r", "--resume": | 203 | case "-r", "--resume": | ||
| 202 | a.Resume = true | 204 | a.Resume = true | ||
| 205 | + | if i+1 < len(in) { | |||
| 206 | + | if id, err := uuid.Parse(in[i+1]); err == nil { | |||
| 207 | + | a.ResumeSessionID = id.String() | |||
| 208 | + | i++ | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 203 | case "--no-session": | 211 | case "--no-session": | ||
| 204 | a.NoSess = true | 212 | a.NoSess = true | ||
| 205 | case "--no-tools": | 213 | case "--no-tools": | ||
| @@ -535,7 +543,7 @@ func PrintHelp(version string) { | |||||
| 535 | row{"--system-prompt TEXT", "replace the default system prompt"}, | 543 | row{"--system-prompt TEXT", "replace the default system prompt"}, | ||
| 536 | row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"}, | 544 | row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"}, | ||
| 537 | row{"-c, --continue", "continue the most recent session for this cwd"}, | 545 | row{"-c, --continue", "continue the most recent session for this cwd"}, | ||
| 538 | - | row{"-r, --resume", "pick a session to resume"}, | 546 | + | row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"}, |
| 539 | row{"--session PATH", "resume a specific session file"}, | 547 | row{"--session PATH", "resume a specific session file"}, | ||
| 540 | row{"--no-session", "do not read or write a session file"}, | 548 | row{"--no-session", "do not read or write a session file"}, | ||
| 541 | ) | 549 | ) | ||
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 +64 -8
| @@ -910,7 +910,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 910 | 910 | } | |
| 911 | 911 | }() | |
| 912 | 912 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 913 | - | sess, err := openOrCreateSession(args, r, ag, version) | |
| 913 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 914 | 914 | if err != nil { | |
| 915 | 915 | return err | |
| 916 | 916 | } | |
| @@ -988,7 +988,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||
| 988 | 988 | } | |
| 989 | 989 | }() | |
| 990 | 990 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 991 | - | sess, err := openOrCreateSession(args, r, ag, version) | |
| 991 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 992 | 992 | if err != nil { | |
| 993 | 993 | return err | |
| 994 | 994 | } | |
| @@ -1105,7 +1105,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1105 | 1105 | } | |
| 1106 | 1106 | }() | |
| 1107 | 1107 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 1108 | - | sess, err := openOrCreateSession(args, r, ag, version) | |
| 1108 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 1109 | 1109 | if err != nil { | |
| 1110 | 1110 | return err | |
| 1111 | 1111 | } | |
| @@ -1738,7 +1738,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1738 | 1738 | var sessionTitlePending bool | |
| 1739 | 1739 | if !args.NoSess && ag != nil { | |
| 1740 | 1740 | var sessErr error | |
| 1741 | - | sess, sessErr = openOrCreateSession(args, r, ag, version) | |
| 1741 | + | sess, sessErr = openOrCreateSession(ctx, args, r, ag, version) | |
| 1742 | 1742 | if sessErr != nil { | |
| 1743 | 1743 | return sessErr | |
| 1744 | 1744 | } | |
| @@ -1766,6 +1766,20 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1766 | 1766 | } | |
| 1767 | 1767 | } | |
| 1768 | 1768 | announceSession(extMgr, sess) | |
| 1769 | + | // Print the hint after the session close defer below and all terminal/TUI | |
| 1770 | + | // cleanup defers. Close removes a fresh empty session, so only a session | |
| 1771 | + | // that remains discoverable through the hinted command gets a hint. | |
| 1772 | + | defer func() { | |
| 1773 | + | if !iv.ExitedViaCtrlC() { | |
| 1774 | + | return | |
| 1775 | + | } | |
| 1776 | + | persistMu.Lock() | |
| 1777 | + | current := sess | |
| 1778 | + | persistMu.Unlock() | |
| 1779 | + | if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" { | |
| 1780 | + | fmt.Print(resumeSessionHint(resumeID)) | |
| 1781 | + | } | |
| 1782 | + | }() | |
| 1769 | 1783 | defer func() { | |
| 1770 | 1784 | persistMu.Lock() | |
| 1771 | 1785 | defer persistMu.Unlock() | |
| @@ -2634,6 +2648,34 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2634 | 2648 | return runErr | |
| 2635 | 2649 | } | |
| 2636 | 2650 | ||
| 2651 | + | func resumableSessionID(root string, sess *core.Session) string { | |
| 2652 | + | if sess == nil || sess.ID == "" || sess.Path == "" { | |
| 2653 | + | return "" | |
| 2654 | + | } | |
| 2655 | + | path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID) | |
| 2656 | + | if err != nil || !sameSessionPath(path, sess.Path) { | |
| 2657 | + | return "" | |
| 2658 | + | } | |
| 2659 | + | snapshot, err := core.ReadSessionSnapshot(path) | |
| 2660 | + | if err != nil || len(snapshot.Messages) == 0 { | |
| 2661 | + | return "" | |
| 2662 | + | } | |
| 2663 | + | return sess.ID | |
| 2664 | + | } | |
| 2665 | + | ||
| 2666 | + | func sameSessionPath(left, right string) bool { | |
| 2667 | + | leftAbs, leftErr := filepath.Abs(left) | |
| 2668 | + | rightAbs, rightErr := filepath.Abs(right) | |
| 2669 | + | if leftErr == nil && rightErr == nil { | |
| 2670 | + | return filepath.Clean(leftAbs) == filepath.Clean(rightAbs) | |
| 2671 | + | } | |
| 2672 | + | return filepath.Clean(left) == filepath.Clean(right) | |
| 2673 | + | } | |
| 2674 | + | ||
| 2675 | + | func resumeSessionHint(id string) string { | |
| 2676 | + | return fmt.Sprintf("Resume this session with: zut --resume %s\n", id) | |
| 2677 | + | } | |
| 2678 | + | ||
| 2637 | 2679 | func agentSessionsRoot(root string, args Args) string { | |
| 2638 | 2680 | if args.AgentName == "" { | |
| 2639 | 2681 | return root | |
| @@ -2643,15 +2685,18 @@ func agentSessionsRoot(root string, args Args) string { | |||
| 2643 | 2685 | ||
| 2644 | 2686 | // openOrCreateSession returns a session for the run. sess may be nil | |
| 2645 | 2687 | // with a nil error if session persistence is disabled. | |
| 2646 | - | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| 2688 | + | func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| 2647 | 2689 | if args.NoSess { | |
| 2648 | 2690 | return nil, nil | |
| 2649 | 2691 | } | |
| 2650 | 2692 | // Sweep meta-only files left over from older zut versions (and from | |
| 2651 | - | // any session that crashed before its first AppendMessage). Cheap; | |
| 2652 | - | // reads the first few bytes of each file in the cwd's session dir. | |
| 2693 | + | // any session that crashed before its first AppendMessage) for the | |
| 2694 | + | // cwd-scoped picker paths. Explicit UUID lookup stays strict so it | |
| 2695 | + | // can report metadata failures instead of turning them into misses. | |
| 2653 | 2696 | sessionsRoot := agentSessionsRoot(ZutHome(), args) | |
| 2654 | - | core.PruneEmptySessions(sessionsRoot, args.CWD) | |
| 2697 | + | if args.ResumeSessionID == "" { | |
| 2698 | + | core.PruneEmptySessions(sessionsRoot, args.CWD) | |
| 2699 | + | } | |
| 2655 | 2700 | var ( | |
| 2656 | 2701 | s *core.Session | |
| 2657 | 2702 | msgs []provider.Message | |
| @@ -2678,6 +2723,17 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 2678 | 2723 | s, msgs, err = core.OpenSession(latest) | |
| 2679 | 2724 | } | |
| 2680 | 2725 | case args.Resume: | |
| 2726 | + | if args.ResumeSessionID != "" { | |
| 2727 | + | picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID) | |
| 2728 | + | if lookupErr != nil { | |
| 2729 | + | return nil, lookupErr | |
| 2730 | + | } | |
| 2731 | + | if picked == "" { | |
| 2732 | + | return nil, fmt.Errorf("session %q not found", args.ResumeSessionID) | |
| 2733 | + | } | |
| 2734 | + | s, msgs, err = core.OpenSession(picked) | |
| 2735 | + | break | |
| 2736 | + | } | |
| 2681 | 2737 | picked, perr := pickSession(sessionsRoot, args.CWD) | |
| 2682 | 2738 | if perr != nil { | |
| 2683 | 2739 | return nil, perr | |
| @@ -910,7 +910,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 910 | } | 910 | } | ||
| 911 | }() | 911 | }() | ||
| 912 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 912 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | ||
| 913 | - | sess, err := openOrCreateSession(args, r, ag, version) | 913 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) |
| 914 | if err != nil { | 914 | if err != nil { | ||
| 915 | return err | 915 | return err | ||
| 916 | } | 916 | } | ||
| @@ -988,7 +988,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||||
| 988 | } | 988 | } | ||
| 989 | }() | 989 | }() | ||
| 990 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 990 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | ||
| 991 | - | sess, err := openOrCreateSession(args, r, ag, version) | 991 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) |
| 992 | if err != nil { | 992 | if err != nil { | ||
| 993 | return err | 993 | return err | ||
| 994 | } | 994 | } | ||
| @@ -1105,7 +1105,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 1105 | } | 1105 | } | ||
| 1106 | }() | 1106 | }() | ||
| 1107 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 1107 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | ||
| 1108 | - | sess, err := openOrCreateSession(args, r, ag, version) | 1108 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) |
| 1109 | if err != nil { | 1109 | if err != nil { | ||
| 1110 | return err | 1110 | return err | ||
| 1111 | } | 1111 | } | ||
| @@ -1738,7 +1738,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1738 | var sessionTitlePending bool | 1738 | var sessionTitlePending bool | ||
| 1739 | if !args.NoSess && ag != nil { | 1739 | if !args.NoSess && ag != nil { | ||
| 1740 | var sessErr error | 1740 | var sessErr error | ||
| 1741 | - | sess, sessErr = openOrCreateSession(args, r, ag, version) | 1741 | + | sess, sessErr = openOrCreateSession(ctx, args, r, ag, version) |
| 1742 | if sessErr != nil { | 1742 | if sessErr != nil { | ||
| 1743 | return sessErr | 1743 | return sessErr | ||
| 1744 | } | 1744 | } | ||
| @@ -1766,6 +1766,20 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1766 | } | 1766 | } | ||
| 1767 | } | 1767 | } | ||
| 1768 | announceSession(extMgr, sess) | 1768 | announceSession(extMgr, sess) | ||
| 1769 | + | // Print the hint after the session close defer below and all terminal/TUI | |||
| 1770 | + | // cleanup defers. Close removes a fresh empty session, so only a session | |||
| 1771 | + | // that remains discoverable through the hinted command gets a hint. | |||
| 1772 | + | defer func() { | |||
| 1773 | + | if !iv.ExitedViaCtrlC() { | |||
| 1774 | + | return | |||
| 1775 | + | } | |||
| 1776 | + | persistMu.Lock() | |||
| 1777 | + | current := sess | |||
| 1778 | + | persistMu.Unlock() | |||
| 1779 | + | if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" { | |||
| 1780 | + | fmt.Print(resumeSessionHint(resumeID)) | |||
| 1781 | + | } | |||
| 1782 | + | }() | |||
| 1769 | defer func() { | 1783 | defer func() { | ||
| 1770 | persistMu.Lock() | 1784 | persistMu.Lock() | ||
| 1771 | defer persistMu.Unlock() | 1785 | defer persistMu.Unlock() | ||
| @@ -2634,6 +2648,34 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2634 | return runErr | 2648 | return runErr | ||
| 2635 | } | 2649 | } | ||
| 2636 | 2650 | ||||
| 2651 | + | func resumableSessionID(root string, sess *core.Session) string { | |||
| 2652 | + | if sess == nil || sess.ID == "" || sess.Path == "" { | |||
| 2653 | + | return "" | |||
| 2654 | + | } | |||
| 2655 | + | path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID) | |||
| 2656 | + | if err != nil || !sameSessionPath(path, sess.Path) { | |||
| 2657 | + | return "" | |||
| 2658 | + | } | |||
| 2659 | + | snapshot, err := core.ReadSessionSnapshot(path) | |||
| 2660 | + | if err != nil || len(snapshot.Messages) == 0 { | |||
| 2661 | + | return "" | |||
| 2662 | + | } | |||
| 2663 | + | return sess.ID | |||
| 2664 | + | } | |||
| 2665 | + | ||||
| 2666 | + | func sameSessionPath(left, right string) bool { | |||
| 2667 | + | leftAbs, leftErr := filepath.Abs(left) | |||
| 2668 | + | rightAbs, rightErr := filepath.Abs(right) | |||
| 2669 | + | if leftErr == nil && rightErr == nil { | |||
| 2670 | + | return filepath.Clean(leftAbs) == filepath.Clean(rightAbs) | |||
| 2671 | + | } | |||
| 2672 | + | return filepath.Clean(left) == filepath.Clean(right) | |||
| 2673 | + | } | |||
| 2674 | + | ||||
| 2675 | + | func resumeSessionHint(id string) string { | |||
| 2676 | + | return fmt.Sprintf("Resume this session with: zut --resume %s\n", id) | |||
| 2677 | + | } | |||
| 2678 | + | ||||
| 2637 | func agentSessionsRoot(root string, args Args) string { | 2679 | func agentSessionsRoot(root string, args Args) string { | ||
| 2638 | if args.AgentName == "" { | 2680 | if args.AgentName == "" { | ||
| 2639 | return root | 2681 | return root | ||
| @@ -2643,15 +2685,18 @@ func agentSessionsRoot(root string, args Args) string { | |||||
| 2643 | 2685 | ||||
| 2644 | // openOrCreateSession returns a session for the run. sess may be nil | 2686 | // openOrCreateSession returns a session for the run. sess may be nil | ||
| 2645 | // with a nil error if session persistence is disabled. | 2687 | // with a nil error if session persistence is disabled. | ||
| 2646 | - | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | 2688 | + | func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { |
| 2647 | if args.NoSess { | 2689 | if args.NoSess { | ||
| 2648 | return nil, nil | 2690 | return nil, nil | ||
| 2649 | } | 2691 | } | ||
| 2650 | // Sweep meta-only files left over from older zut versions (and from | 2692 | // Sweep meta-only files left over from older zut versions (and from | ||
| 2651 | - | // any session that crashed before its first AppendMessage). Cheap; | 2693 | + | // any session that crashed before its first AppendMessage) for the |
| 2652 | - | // reads the first few bytes of each file in the cwd's session dir. | 2694 | + | // cwd-scoped picker paths. Explicit UUID lookup stays strict so it |
| 2695 | + | // can report metadata failures instead of turning them into misses. | |||
| 2653 | sessionsRoot := agentSessionsRoot(ZutHome(), args) | 2696 | sessionsRoot := agentSessionsRoot(ZutHome(), args) | ||
| 2654 | - | core.PruneEmptySessions(sessionsRoot, args.CWD) | 2697 | + | if args.ResumeSessionID == "" { |
| 2698 | + | core.PruneEmptySessions(sessionsRoot, args.CWD) | |||
| 2699 | + | } | |||
| 2655 | var ( | 2700 | var ( | ||
| 2656 | s *core.Session | 2701 | s *core.Session | ||
| 2657 | msgs []provider.Message | 2702 | msgs []provider.Message | ||
| @@ -2678,6 +2723,17 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 2678 | s, msgs, err = core.OpenSession(latest) | 2723 | s, msgs, err = core.OpenSession(latest) | ||
| 2679 | } | 2724 | } | ||
| 2680 | case args.Resume: | 2725 | case args.Resume: | ||
| 2726 | + | if args.ResumeSessionID != "" { | |||
| 2727 | + | picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID) | |||
| 2728 | + | if lookupErr != nil { | |||
| 2729 | + | return nil, lookupErr | |||
| 2730 | + | } | |||
| 2731 | + | if picked == "" { | |||
| 2732 | + | return nil, fmt.Errorf("session %q not found", args.ResumeSessionID) | |||
| 2733 | + | } | |||
| 2734 | + | s, msgs, err = core.OpenSession(picked) | |||
| 2735 | + | break | |||
| 2736 | + | } | |||
| 2681 | picked, perr := pickSession(sessionsRoot, args.CWD) | 2737 | picked, perr := pickSession(sessionsRoot, args.CWD) | ||
| 2682 | if perr != nil { | 2738 | if perr != nil { | ||
| 2683 | return nil, perr | 2739 | return nil, perr | ||
M packages/agent/cli_session_test.go +223 -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,229 @@ 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(context.Background(), 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(context.Background(), 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 TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) { | |
| 135 | + | root := t.TempDir() | |
| 136 | + | cwd := t.TempDir() | |
| 137 | + | otherCWD := t.TempDir() | |
| 138 | + | t.Setenv("ZUT_HOME", root) | |
| 139 | + | ||
| 140 | + | dir := core.SessionsDir(root, cwd) | |
| 141 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | id := uuid.NewString() | |
| 145 | + | metaLine := struct { | |
| 146 | + | Type string `json:"type"` | |
| 147 | + | Meta core.SessionMeta `json:"meta"` | |
| 148 | + | }{ | |
| 149 | + | Type: "meta", | |
| 150 | + | Meta: core.SessionMeta{ | |
| 151 | + | ID: id, | |
| 152 | + | CWD: cwd, | |
| 153 | + | Provider: "provider", | |
| 154 | + | Model: "model", | |
| 155 | + | Started: time.Now().UTC(), | |
| 156 | + | Version: "test", | |
| 157 | + | }, | |
| 158 | + | } | |
| 159 | + | data, err := json.Marshal(metaLine) | |
| 160 | + | if err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | path := filepath.Join(dir, "persisted-empty.jsonl") | |
| 164 | + | if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { | |
| 165 | + | t.Fatal(err) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | sess, err := openOrCreateSession(context.Background(), Args{ | |
| 169 | + | CWD: otherCWD, | |
| 170 | + | Resume: true, | |
| 171 | + | ResumeSessionID: id, | |
| 172 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |
| 173 | + | if err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | if sess == nil || sess.Path != path { | |
| 177 | + | t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path) | |
| 178 | + | } | |
| 179 | + | if got := resumableSessionID(root, sess); got != "" { | |
| 180 | + | t.Fatalf("persisted empty session hint ID = %q, want empty", got) | |
| 181 | + | } | |
| 182 | + | if err := sess.Close(); err != nil { | |
| 183 | + | t.Fatal(err) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) { | |
| 188 | + | root := t.TempDir() | |
| 189 | + | cwd := t.TempDir() | |
| 190 | + | t.Setenv("ZUT_HOME", root) | |
| 191 | + | id := uuid.NewString() | |
| 192 | + | ||
| 193 | + | _, err := openOrCreateSession(context.Background(), Args{ | |
| 194 | + | CWD: cwd, | |
| 195 | + | Resume: true, | |
| 196 | + | ResumeSessionID: id, | |
| 197 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |
| 198 | + | if err == nil { | |
| 199 | + | t.Fatal("unknown UUID resume unexpectedly succeeded") | |
| 200 | + | } | |
| 201 | + | if !strings.Contains(err.Error(), id) { | |
| 202 | + | t.Fatalf("unknown UUID error = %v, want session ID", err) | |
| 203 | + | } | |
| 204 | + | if sessions := core.ListSessions(root, cwd); len(sessions) != 0 { | |
| 205 | + | t.Fatalf("unknown UUID created sessions: %v", sessions) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) { | |
| 210 | + | root := t.TempDir() | |
| 211 | + | cwd := t.TempDir() | |
| 212 | + | t.Setenv("ZUT_HOME", root) | |
| 213 | + | if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil { | |
| 214 | + | t.Fatal(err) | |
| 215 | + | } | |
| 216 | + | if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil { | |
| 217 | + | t.Fatal(err) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | _, err := openOrCreateSession(context.Background(), Args{ | |
| 221 | + | CWD: cwd, | |
| 222 | + | Resume: true, | |
| 223 | + | ResumeSessionID: uuid.NewString(), | |
| 224 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |
| 225 | + | if err == nil { | |
| 226 | + | t.Fatal("malformed UUID lookup unexpectedly succeeded") | |
| 227 | + | } | |
| 228 | + | if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") { | |
| 229 | + | t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestResumeSessionHint(t *testing.T) { | |
| 234 | + | id := uuid.NewString() | |
| 235 | + | want := "Resume this session with: zut --resume " + id + "\n" | |
| 236 | + | if got := resumeSessionHint(id); got != want { | |
| 237 | + | t.Fatalf("resume hint = %q, want %q", got, want) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 18 | 241 | func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) { | |
| 19 | 242 | msgs := make([]provider.Message, 0, 101) | |
| 20 | 243 | 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,229 @@ 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(context.Background(), 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(context.Background(), 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 TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) { | |||
| 135 | + | root := t.TempDir() | |||
| 136 | + | cwd := t.TempDir() | |||
| 137 | + | otherCWD := t.TempDir() | |||
| 138 | + | t.Setenv("ZUT_HOME", root) | |||
| 139 | + | ||||
| 140 | + | dir := core.SessionsDir(root, cwd) | |||
| 141 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | id := uuid.NewString() | |||
| 145 | + | metaLine := struct { | |||
| 146 | + | Type string `json:"type"` | |||
| 147 | + | Meta core.SessionMeta `json:"meta"` | |||
| 148 | + | }{ | |||
| 149 | + | Type: "meta", | |||
| 150 | + | Meta: core.SessionMeta{ | |||
| 151 | + | ID: id, | |||
| 152 | + | CWD: cwd, | |||
| 153 | + | Provider: "provider", | |||
| 154 | + | Model: "model", | |||
| 155 | + | Started: time.Now().UTC(), | |||
| 156 | + | Version: "test", | |||
| 157 | + | }, | |||
| 158 | + | } | |||
| 159 | + | data, err := json.Marshal(metaLine) | |||
| 160 | + | if err != nil { | |||
| 161 | + | t.Fatal(err) | |||
| 162 | + | } | |||
| 163 | + | path := filepath.Join(dir, "persisted-empty.jsonl") | |||
| 164 | + | if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { | |||
| 165 | + | t.Fatal(err) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | sess, err := openOrCreateSession(context.Background(), Args{ | |||
| 169 | + | CWD: otherCWD, | |||
| 170 | + | Resume: true, | |||
| 171 | + | ResumeSessionID: id, | |||
| 172 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Fatal(err) | |||
| 175 | + | } | |||
| 176 | + | if sess == nil || sess.Path != path { | |||
| 177 | + | t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path) | |||
| 178 | + | } | |||
| 179 | + | if got := resumableSessionID(root, sess); got != "" { | |||
| 180 | + | t.Fatalf("persisted empty session hint ID = %q, want empty", got) | |||
| 181 | + | } | |||
| 182 | + | if err := sess.Close(); err != nil { | |||
| 183 | + | t.Fatal(err) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) { | |||
| 188 | + | root := t.TempDir() | |||
| 189 | + | cwd := t.TempDir() | |||
| 190 | + | t.Setenv("ZUT_HOME", root) | |||
| 191 | + | id := uuid.NewString() | |||
| 192 | + | ||||
| 193 | + | _, err := openOrCreateSession(context.Background(), Args{ | |||
| 194 | + | CWD: cwd, | |||
| 195 | + | Resume: true, | |||
| 196 | + | ResumeSessionID: id, | |||
| 197 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |||
| 198 | + | if err == nil { | |||
| 199 | + | t.Fatal("unknown UUID resume unexpectedly succeeded") | |||
| 200 | + | } | |||
| 201 | + | if !strings.Contains(err.Error(), id) { | |||
| 202 | + | t.Fatalf("unknown UUID error = %v, want session ID", err) | |||
| 203 | + | } | |||
| 204 | + | if sessions := core.ListSessions(root, cwd); len(sessions) != 0 { | |||
| 205 | + | t.Fatalf("unknown UUID created sessions: %v", sessions) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) { | |||
| 210 | + | root := t.TempDir() | |||
| 211 | + | cwd := t.TempDir() | |||
| 212 | + | t.Setenv("ZUT_HOME", root) | |||
| 213 | + | if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil { | |||
| 214 | + | t.Fatal(err) | |||
| 215 | + | } | |||
| 216 | + | if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil { | |||
| 217 | + | t.Fatal(err) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | _, err := openOrCreateSession(context.Background(), Args{ | |||
| 221 | + | CWD: cwd, | |||
| 222 | + | Resume: true, | |||
| 223 | + | ResumeSessionID: uuid.NewString(), | |||
| 224 | + | }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test") | |||
| 225 | + | if err == nil { | |||
| 226 | + | t.Fatal("malformed UUID lookup unexpectedly succeeded") | |||
| 227 | + | } | |||
| 228 | + | if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") { | |||
| 229 | + | t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestResumeSessionHint(t *testing.T) { | |||
| 234 | + | id := uuid.NewString() | |||
| 235 | + | want := "Resume this session with: zut --resume " + id + "\n" | |||
| 236 | + | if got := resumeSessionHint(id); got != want { | |||
| 237 | + | t.Fatalf("resume hint = %q, want %q", got, want) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 18 | func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) { | 241 | func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) { | ||
| 19 | msgs := make([]provider.Message, 0, 101) | 242 | msgs := make([]provider.Message, 0, 101) | ||
| 20 | msgs = append(msgs, provider.Message{ | 243 | 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
| @@ -667,6 +667,7 @@ type Interactive struct { | |||
| 667 | 667 | cumUsage provider.Usage | |
| 668 | 668 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 669 | 669 | busy bool | |
| 670 | + | ctrlCExit bool | |
| 670 | 671 | activity agentActivity | |
| 671 | 672 | subagentActivityActive bool | |
| 672 | 673 | pendingIdleWork []func() | |
| @@ -1008,6 +1009,21 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1008 | 1009 | return i | |
| 1009 | 1010 | } | |
| 1010 | 1011 | ||
| 1012 | + | // ExitedViaCtrlC reports whether the last Run returned because the user | |
| 1013 | + | // deliberately exited with Ctrl+C. | |
| 1014 | + | func (i *Interactive) ExitedViaCtrlC() bool { | |
| 1015 | + | i.mu.Lock() | |
| 1016 | + | defer i.mu.Unlock() | |
| 1017 | + | return i.ctrlCExit | |
| 1018 | + | } | |
| 1019 | + | ||
| 1020 | + | // markCtrlCExit records that the current Run is ending via Ctrl+C. | |
| 1021 | + | func (i *Interactive) markCtrlCExit() { | |
| 1022 | + | i.mu.Lock() | |
| 1023 | + | i.ctrlCExit = true | |
| 1024 | + | i.mu.Unlock() | |
| 1025 | + | } | |
| 1026 | + | ||
| 1011 | 1027 | // Run blocks until the user quits. | |
| 1012 | 1028 | func (i *Interactive) Run(ctx context.Context) error { | |
| 1013 | 1029 | i.runCtx = ctx | |
| @@ -3176,6 +3192,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3176 | 3192 | loadingSession := i.sessionLoading | |
| 3177 | 3193 | i.mu.Unlock() | |
| 3178 | 3194 | if loadingSession { | |
| 3195 | + | i.markCtrlCExit() | |
| 3179 | 3196 | return true | |
| 3180 | 3197 | } | |
| 3181 | 3198 | // While busy: do NOT cancel the turn. ctrl+c during a | |
| @@ -3188,6 +3205,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3188 | 3205 | // ctrlCExitWindow quits. | |
| 3189 | 3206 | if i.busy { | |
| 3190 | 3207 | if i.ctrlCExitArmed() { | |
| 3208 | + | i.markCtrlCExit() | |
| 3191 | 3209 | return true | |
| 3192 | 3210 | } | |
| 3193 | 3211 | i.mu.Lock() | |
| @@ -3223,6 +3241,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3223 | 3241 | return false | |
| 3224 | 3242 | } | |
| 3225 | 3243 | if i.ctrlCExitArmed() { | |
| 3244 | + | i.markCtrlCExit() | |
| 3226 | 3245 | return true | |
| 3227 | 3246 | } | |
| 3228 | 3247 | i.mu.Lock() | |
| @@ -667,6 +667,7 @@ type Interactive struct { | |||||
| 667 | cumUsage provider.Usage | 667 | cumUsage provider.Usage | ||
| 668 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 668 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 669 | busy bool | 669 | busy bool | ||
| 670 | + | ctrlCExit bool | |||
| 670 | activity agentActivity | 671 | activity agentActivity | ||
| 671 | subagentActivityActive bool | 672 | subagentActivityActive bool | ||
| 672 | pendingIdleWork []func() | 673 | pendingIdleWork []func() | ||
| @@ -1008,6 +1009,21 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 1008 | return i | 1009 | return i | ||
| 1009 | } | 1010 | } | ||
| 1010 | 1011 | ||||
| 1012 | + | // ExitedViaCtrlC reports whether the last Run returned because the user | |||
| 1013 | + | // deliberately exited with Ctrl+C. | |||
| 1014 | + | func (i *Interactive) ExitedViaCtrlC() bool { | |||
| 1015 | + | i.mu.Lock() | |||
| 1016 | + | defer i.mu.Unlock() | |||
| 1017 | + | return i.ctrlCExit | |||
| 1018 | + | } | |||
| 1019 | + | ||||
| 1020 | + | // markCtrlCExit records that the current Run is ending via Ctrl+C. | |||
| 1021 | + | func (i *Interactive) markCtrlCExit() { | |||
| 1022 | + | i.mu.Lock() | |||
| 1023 | + | i.ctrlCExit = true | |||
| 1024 | + | i.mu.Unlock() | |||
| 1025 | + | } | |||
| 1026 | + | ||||
| 1011 | // Run blocks until the user quits. | 1027 | // Run blocks until the user quits. | ||
| 1012 | func (i *Interactive) Run(ctx context.Context) error { | 1028 | func (i *Interactive) Run(ctx context.Context) error { | ||
| 1013 | i.runCtx = ctx | 1029 | i.runCtx = ctx | ||
| @@ -3176,6 +3192,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3176 | loadingSession := i.sessionLoading | 3192 | loadingSession := i.sessionLoading | ||
| 3177 | i.mu.Unlock() | 3193 | i.mu.Unlock() | ||
| 3178 | if loadingSession { | 3194 | if loadingSession { | ||
| 3195 | + | i.markCtrlCExit() | |||
| 3179 | return true | 3196 | return true | ||
| 3180 | } | 3197 | } | ||
| 3181 | // While busy: do NOT cancel the turn. ctrl+c during a | 3198 | // While busy: do NOT cancel the turn. ctrl+c during a | ||
| @@ -3188,6 +3205,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3188 | // ctrlCExitWindow quits. | 3205 | // ctrlCExitWindow quits. | ||
| 3189 | if i.busy { | 3206 | if i.busy { | ||
| 3190 | if i.ctrlCExitArmed() { | 3207 | if i.ctrlCExitArmed() { | ||
| 3208 | + | i.markCtrlCExit() | |||
| 3191 | return true | 3209 | return true | ||
| 3192 | } | 3210 | } | ||
| 3193 | i.mu.Lock() | 3211 | i.mu.Lock() | ||
| @@ -3223,6 +3241,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3223 | return false | 3241 | return false | ||
| 3224 | } | 3242 | } | ||
| 3225 | if i.ctrlCExitArmed() { | 3243 | if i.ctrlCExitArmed() { | ||
| 3244 | + | i.markCtrlCExit() | |||
| 3226 | return true | 3245 | return true | ||
| 3227 | } | 3246 | } | ||
| 3228 | i.mu.Lock() | 3247 | i.mu.Lock() | ||
M packages/agent/subagent_worker.go +1 -1
| @@ -124,7 +124,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||
| 124 | 124 | } | |
| 125 | 125 | }() | |
| 126 | 126 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 127 | - | sess, err := openOrCreateSession(args, r, ag, version) | |
| 127 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) | |
| 128 | 128 | if err != nil { | |
| 129 | 129 | return err | |
| 130 | 130 | } | |
| @@ -124,7 +124,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||||
| 124 | } | 124 | } | ||
| 125 | }() | 125 | }() | ||
| 126 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | 126 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | ||
| 127 | - | sess, err := openOrCreateSession(args, r, ag, version) | 127 | + | sess, err := openOrCreateSession(ctx, args, r, ag, version) |
| 128 | if err != nil { | 128 | if err != nil { | ||
| 129 | return err | 129 | return err | ||
| 130 | } | 130 | } | ||
M packages/core/session_portable.go +112 -1
| @@ -3,6 +3,7 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | 5 | "bytes" | |
| 6 | + | "context" | |
| 6 | 7 | "encoding/json" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "fmt" | |
| @@ -803,6 +804,13 @@ func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNod | |||
| 803 | 804 | // small projection; full snapshot validation remains the responsibility of | |
| 804 | 805 | // resume, export, branching, and tree preflight callers. | |
| 805 | 806 | func scanSessionMeta(path string) (meta SessionMeta, err error) { | |
| 807 | + | return scanSessionMetaContext(context.Background(), path) | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) { | |
| 811 | + | if err := contextErr(ctx); err != nil { | |
| 812 | + | return SessionMeta{}, err | |
| 813 | + | } | |
| 806 | 814 | f, err := os.Open(path) | |
| 807 | 815 | if err != nil { | |
| 808 | 816 | return SessionMeta{}, err | |
| @@ -810,7 +818,7 @@ func scanSessionMeta(path string) (meta SessionMeta, err error) { | |||
| 810 | 818 | defer f.Close() | |
| 811 | 819 | ||
| 812 | 820 | var sawMeta bool | |
| 813 | - | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | |
| 821 | + | err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error { | |
| 814 | 822 | var head sessionLineHead | |
| 815 | 823 | if err := json.Unmarshal(line, &head); err != nil { | |
| 816 | 824 | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | |
| @@ -882,6 +890,109 @@ func FindSessionByID(root, cwd, id string) string { | |||
| 882 | 890 | return "" | |
| 883 | 891 | } | |
| 884 | 892 | ||
| 893 | + | // FindManagedSessionByID looks up a session UUID across zut's managed | |
| 894 | + | // session stores, independent of cwd. A nil error and empty path mean no | |
| 895 | + | // matching session was found. Read, storage, metadata, and duplicate-ID | |
| 896 | + | // failures are returned to the caller instead of being reported as a miss. | |
| 897 | + | func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) { | |
| 898 | + | if strings.TrimSpace(root) == "" { | |
| 899 | + | return "", errors.New("find managed session: root is empty") | |
| 900 | + | } | |
| 901 | + | if strings.TrimSpace(id) == "" { | |
| 902 | + | return "", errors.New("find managed session: id is empty") | |
| 903 | + | } | |
| 904 | + | ||
| 905 | + | stores := []string{root} | |
| 906 | + | agentsDir := filepath.Join(root, "sessions", "agents") | |
| 907 | + | agents, err := os.ReadDir(agentsDir) | |
| 908 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 909 | + | return "", fmt.Errorf("find managed session: read agent stores: %w", err) | |
| 910 | + | } | |
| 911 | + | if err == nil { | |
| 912 | + | for _, entry := range agents { | |
| 913 | + | if entry.IsDir() { | |
| 914 | + | stores = append(stores, filepath.Join(agentsDir, entry.Name())) | |
| 915 | + | } | |
| 916 | + | } | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | var matches []string | |
| 920 | + | for _, store := range stores { | |
| 921 | + | if err := contextErr(ctx); err != nil { | |
| 922 | + | return "", err | |
| 923 | + | } | |
| 924 | + | if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil { | |
| 925 | + | return "", err | |
| 926 | + | } | |
| 927 | + | } | |
| 928 | + | if err := contextErr(ctx); err != nil { | |
| 929 | + | return "", err | |
| 930 | + | } | |
| 931 | + | if len(matches) == 0 { | |
| 932 | + | return "", nil | |
| 933 | + | } | |
| 934 | + | if len(matches) > 1 { | |
| 935 | + | return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id) | |
| 936 | + | } | |
| 937 | + | return matches[0], nil | |
| 938 | + | } | |
| 939 | + | ||
| 940 | + | func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error { | |
| 941 | + | if err := contextErr(ctx); err != nil { | |
| 942 | + | return err | |
| 943 | + | } | |
| 944 | + | dir := filepath.Join(root, "sessions") | |
| 945 | + | entries, err := os.ReadDir(dir) | |
| 946 | + | if errors.Is(err, os.ErrNotExist) { | |
| 947 | + | return nil | |
| 948 | + | } | |
| 949 | + | if err != nil { | |
| 950 | + | return fmt.Errorf("find managed session: read %q: %w", dir, err) | |
| 951 | + | } | |
| 952 | + | for _, bucket := range entries { | |
| 953 | + | if err := contextErr(ctx); err != nil { | |
| 954 | + | return err | |
| 955 | + | } | |
| 956 | + | if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) { | |
| 957 | + | continue | |
| 958 | + | } | |
| 959 | + | bucketPath := filepath.Join(dir, bucket.Name()) | |
| 960 | + | files, err := os.ReadDir(bucketPath) | |
| 961 | + | if err != nil { | |
| 962 | + | return fmt.Errorf("find managed session: read %q: %w", bucketPath, err) | |
| 963 | + | } | |
| 964 | + | for _, file := range files { | |
| 965 | + | if err := contextErr(ctx); err != nil { | |
| 966 | + | return err | |
| 967 | + | } | |
| 968 | + | if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") { | |
| 969 | + | continue | |
| 970 | + | } | |
| 971 | + | path := filepath.Join(bucketPath, file.Name()) | |
| 972 | + | meta, err := scanSessionMetaContext(ctx, path) | |
| 973 | + | if err != nil { | |
| 974 | + | return fmt.Errorf("find managed session: read metadata %q: %w", path, err) | |
| 975 | + | } | |
| 976 | + | if meta.ID == id { | |
| 977 | + | *matches = append(*matches, path) | |
| 978 | + | } | |
| 979 | + | } | |
| 980 | + | } | |
| 981 | + | return nil | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | func isSessionBucketName(name string) bool { | |
| 985 | + | if len(name) != 16 { | |
| 986 | + | return false | |
| 987 | + | } | |
| 988 | + | for _, r := range name { | |
| 989 | + | if (r < '0' || r > '9') && (r < 'a' || r > 'f') { | |
| 990 | + | return false | |
| 991 | + | } | |
| 992 | + | } | |
| 993 | + | return true | |
| 994 | + | } | |
| 995 | + | ||
| 885 | 996 | // firstUserPrompt scans forward from the current source position | |
| 886 | 997 | // looking for the first user-role message and returns its text. | |
| 887 | 998 | // Used to build a humane export filename. Uses Reader instead of | |
| @@ -3,6 +3,7 @@ package core | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | + | "context" | |||
| 6 | "encoding/json" | 7 | "encoding/json" | ||
| 7 | "errors" | 8 | "errors" | ||
| 8 | "fmt" | 9 | "fmt" | ||
| @@ -803,6 +804,13 @@ func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNod | |||||
| 803 | // small projection; full snapshot validation remains the responsibility of | 804 | // small projection; full snapshot validation remains the responsibility of | ||
| 804 | // resume, export, branching, and tree preflight callers. | 805 | // resume, export, branching, and tree preflight callers. | ||
| 805 | func scanSessionMeta(path string) (meta SessionMeta, err error) { | 806 | func scanSessionMeta(path string) (meta SessionMeta, err error) { | ||
| 807 | + | return scanSessionMetaContext(context.Background(), path) | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) { | |||
| 811 | + | if err := contextErr(ctx); err != nil { | |||
| 812 | + | return SessionMeta{}, err | |||
| 813 | + | } | |||
| 806 | f, err := os.Open(path) | 814 | f, err := os.Open(path) | ||
| 807 | if err != nil { | 815 | if err != nil { | ||
| 808 | return SessionMeta{}, err | 816 | return SessionMeta{}, err | ||
| @@ -810,7 +818,7 @@ func scanSessionMeta(path string) (meta SessionMeta, err error) { | |||||
| 810 | defer f.Close() | 818 | defer f.Close() | ||
| 811 | 819 | ||||
| 812 | var sawMeta bool | 820 | var sawMeta bool | ||
| 813 | - | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | 821 | + | err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error { |
| 814 | var head sessionLineHead | 822 | var head sessionLineHead | ||
| 815 | if err := json.Unmarshal(line, &head); err != nil { | 823 | if err := json.Unmarshal(line, &head); err != nil { | ||
| 816 | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | 824 | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | ||
| @@ -882,6 +890,109 @@ func FindSessionByID(root, cwd, id string) string { | |||||
| 882 | return "" | 890 | return "" | ||
| 883 | } | 891 | } | ||
| 884 | 892 | ||||
| 893 | + | // FindManagedSessionByID looks up a session UUID across zut's managed | |||
| 894 | + | // session stores, independent of cwd. A nil error and empty path mean no | |||
| 895 | + | // matching session was found. Read, storage, metadata, and duplicate-ID | |||
| 896 | + | // failures are returned to the caller instead of being reported as a miss. | |||
| 897 | + | func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) { | |||
| 898 | + | if strings.TrimSpace(root) == "" { | |||
| 899 | + | return "", errors.New("find managed session: root is empty") | |||
| 900 | + | } | |||
| 901 | + | if strings.TrimSpace(id) == "" { | |||
| 902 | + | return "", errors.New("find managed session: id is empty") | |||
| 903 | + | } | |||
| 904 | + | ||||
| 905 | + | stores := []string{root} | |||
| 906 | + | agentsDir := filepath.Join(root, "sessions", "agents") | |||
| 907 | + | agents, err := os.ReadDir(agentsDir) | |||
| 908 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 909 | + | return "", fmt.Errorf("find managed session: read agent stores: %w", err) | |||
| 910 | + | } | |||
| 911 | + | if err == nil { | |||
| 912 | + | for _, entry := range agents { | |||
| 913 | + | if entry.IsDir() { | |||
| 914 | + | stores = append(stores, filepath.Join(agentsDir, entry.Name())) | |||
| 915 | + | } | |||
| 916 | + | } | |||
| 917 | + | } | |||
| 918 | + | ||||
| 919 | + | var matches []string | |||
| 920 | + | for _, store := range stores { | |||
| 921 | + | if err := contextErr(ctx); err != nil { | |||
| 922 | + | return "", err | |||
| 923 | + | } | |||
| 924 | + | if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil { | |||
| 925 | + | return "", err | |||
| 926 | + | } | |||
| 927 | + | } | |||
| 928 | + | if err := contextErr(ctx); err != nil { | |||
| 929 | + | return "", err | |||
| 930 | + | } | |||
| 931 | + | if len(matches) == 0 { | |||
| 932 | + | return "", nil | |||
| 933 | + | } | |||
| 934 | + | if len(matches) > 1 { | |||
| 935 | + | return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id) | |||
| 936 | + | } | |||
| 937 | + | return matches[0], nil | |||
| 938 | + | } | |||
| 939 | + | ||||
| 940 | + | func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error { | |||
| 941 | + | if err := contextErr(ctx); err != nil { | |||
| 942 | + | return err | |||
| 943 | + | } | |||
| 944 | + | dir := filepath.Join(root, "sessions") | |||
| 945 | + | entries, err := os.ReadDir(dir) | |||
| 946 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 947 | + | return nil | |||
| 948 | + | } | |||
| 949 | + | if err != nil { | |||
| 950 | + | return fmt.Errorf("find managed session: read %q: %w", dir, err) | |||
| 951 | + | } | |||
| 952 | + | for _, bucket := range entries { | |||
| 953 | + | if err := contextErr(ctx); err != nil { | |||
| 954 | + | return err | |||
| 955 | + | } | |||
| 956 | + | if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) { | |||
| 957 | + | continue | |||
| 958 | + | } | |||
| 959 | + | bucketPath := filepath.Join(dir, bucket.Name()) | |||
| 960 | + | files, err := os.ReadDir(bucketPath) | |||
| 961 | + | if err != nil { | |||
| 962 | + | return fmt.Errorf("find managed session: read %q: %w", bucketPath, err) | |||
| 963 | + | } | |||
| 964 | + | for _, file := range files { | |||
| 965 | + | if err := contextErr(ctx); err != nil { | |||
| 966 | + | return err | |||
| 967 | + | } | |||
| 968 | + | if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") { | |||
| 969 | + | continue | |||
| 970 | + | } | |||
| 971 | + | path := filepath.Join(bucketPath, file.Name()) | |||
| 972 | + | meta, err := scanSessionMetaContext(ctx, path) | |||
| 973 | + | if err != nil { | |||
| 974 | + | return fmt.Errorf("find managed session: read metadata %q: %w", path, err) | |||
| 975 | + | } | |||
| 976 | + | if meta.ID == id { | |||
| 977 | + | *matches = append(*matches, path) | |||
| 978 | + | } | |||
| 979 | + | } | |||
| 980 | + | } | |||
| 981 | + | return nil | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | func isSessionBucketName(name string) bool { | |||
| 985 | + | if len(name) != 16 { | |||
| 986 | + | return false | |||
| 987 | + | } | |||
| 988 | + | for _, r := range name { | |||
| 989 | + | if (r < '0' || r > '9') && (r < 'a' || r > 'f') { | |||
| 990 | + | return false | |||
| 991 | + | } | |||
| 992 | + | } | |||
| 993 | + | return true | |||
| 994 | + | } | |||
| 995 | + | ||||
| 885 | // firstUserPrompt scans forward from the current source position | 996 | // firstUserPrompt scans forward from the current source position | ||
| 886 | // looking for the first user-role message and returns its text. | 997 | // looking for the first user-role message and returns its text. | ||
| 887 | // Used to build a humane export filename. Uses Reader instead of | 998 | // Used to build a humane export filename. Uses Reader instead of | ||
M packages/core/session_portable_test.go +47 -0
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 5 | 7 | "os" | |
| 6 | 8 | "path/filepath" | |
| 7 | 9 | "strings" | |
| @@ -10,6 +12,51 @@ import ( | |||
| 10 | 12 | "github.com/bnema/zut/packages/provider" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 15 | + | func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) { | |
| 16 | + | root := t.TempDir() | |
| 17 | + | cwd := t.TempDir() | |
| 18 | + | session, err := NewSession(root, cwd, "provider", "model", "test") | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | if err := session.AppendMessage(provider.Message{ | |
| 23 | + | Role: provider.RoleUser, | |
| 24 | + | Content: []provider.Content{provider.TextBlock{Text: "managed"}}, | |
| 25 | + | }); err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | if err := session.Close(); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | got, err := FindManagedSessionByID(context.Background(), root, session.ID) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatalf("FindManagedSessionByID: %v", err) | |
| 35 | + | } | |
| 36 | + | if got != session.Path { | |
| 37 | + | t.Fatalf("managed session path = %q, want %q", got, session.Path) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl") | |
| 41 | + | if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | if _, err := FindManagedSessionByID(context.Background(), root, "missing"); err == nil { | |
| 45 | + | t.Fatal("malformed managed metadata was reported as not found") | |
| 46 | + | } else if !strings.Contains(err.Error(), "metadata") { | |
| 47 | + | t.Fatalf("malformed metadata error = %v, want metadata context", err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestFindManagedSessionByIDHonorsCancellation(t *testing.T) { | |
| 52 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 53 | + | cancel() | |
| 54 | + | ||
| 55 | + | if _, err := FindManagedSessionByID(ctx, t.TempDir(), "session-id"); !errors.Is(err, context.Canceled) { | |
| 56 | + | t.Fatalf("FindManagedSessionByID error = %v, want context.Canceled", err) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 13 | 60 | // TestSessionExportImportRoundTrip writes a few messages to a live | |
| 14 | 61 | // session, exports it, imports the export under a different cwd, | |
| 15 | 62 | // and verifies OpenSession on the imported file yields the same | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 5 | "os" | 7 | "os" | ||
| 6 | "path/filepath" | 8 | "path/filepath" | ||
| 7 | "strings" | 9 | "strings" | ||
| @@ -10,6 +12,51 @@ import ( | |||||
| 10 | "github.com/bnema/zut/packages/provider" | 12 | "github.com/bnema/zut/packages/provider" | ||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 15 | + | func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) { | |||
| 16 | + | root := t.TempDir() | |||
| 17 | + | cwd := t.TempDir() | |||
| 18 | + | session, err := NewSession(root, cwd, "provider", "model", "test") | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | if err := session.AppendMessage(provider.Message{ | |||
| 23 | + | Role: provider.RoleUser, | |||
| 24 | + | Content: []provider.Content{provider.TextBlock{Text: "managed"}}, | |||
| 25 | + | }); err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | if err := session.Close(); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | got, err := FindManagedSessionByID(context.Background(), root, session.ID) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatalf("FindManagedSessionByID: %v", err) | |||
| 35 | + | } | |||
| 36 | + | if got != session.Path { | |||
| 37 | + | t.Fatalf("managed session path = %q, want %q", got, session.Path) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl") | |||
| 41 | + | if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | if _, err := FindManagedSessionByID(context.Background(), root, "missing"); err == nil { | |||
| 45 | + | t.Fatal("malformed managed metadata was reported as not found") | |||
| 46 | + | } else if !strings.Contains(err.Error(), "metadata") { | |||
| 47 | + | t.Fatalf("malformed metadata error = %v, want metadata context", err) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestFindManagedSessionByIDHonorsCancellation(t *testing.T) { | |||
| 52 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 53 | + | cancel() | |||
| 54 | + | ||||
| 55 | + | if _, err := FindManagedSessionByID(ctx, t.TempDir(), "session-id"); !errors.Is(err, context.Canceled) { | |||
| 56 | + | t.Fatalf("FindManagedSessionByID error = %v, want context.Canceled", err) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 13 | // TestSessionExportImportRoundTrip writes a few messages to a live | 60 | // TestSessionExportImportRoundTrip writes a few messages to a live | ||
| 14 | // session, exports it, imports the export under a different cwd, | 61 | // session, exports it, imports the export under a different cwd, | ||
| 15 | // and verifies OpenSession on the imported file yields the same | 62 | // and verifies OpenSession on the imported file yields the same | ||