mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* feat(tui): add isolated btw conversations Add /btw with inline-question support, isolated non-resumable session forks, background main-run routing, and safe return behavior. Refs #637 * test(tui): cover btw return controls Preserve drafts when Ctrl+C is pressed during an active BTW run and cover returning to the parent through the documented /btw toggle. * Address BTW isolation review feedback * Reject explicit resumes of side sessions * Harden BTW isolation lifecycle
289 lines
11 KiB
Go
289 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
)
|
|
|
|
func TestRunSessionsListsLineageAndTree(t *testing.T) {
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: sequenceClockCLI([]time.Time{
|
|
time.Date(2026, 6, 4, 19, 0, 0, 0, time.UTC),
|
|
time.Date(2026, 6, 4, 19, 0, 1, 0, time.UTC),
|
|
time.Date(2026, 6, 4, 19, 0, 2, 0, time.UTC),
|
|
time.Date(2026, 6, 4, 19, 0, 3, 0, time.UTC),
|
|
time.Date(2026, 6, 4, 19, 0, 4, 0, time.UTC),
|
|
})})
|
|
root, err := store.Create(sessions.CreateInput{SessionID: "root", Title: "Root session"})
|
|
if err != nil {
|
|
t.Fatalf("Create returned error: %v", err)
|
|
}
|
|
child, err := store.CreateChild(root.SessionID, sessions.ChildInput{
|
|
SessionID: "child",
|
|
Title: "Review child",
|
|
Tag: "specialist",
|
|
Depth: 1,
|
|
AgentName: "code-review",
|
|
TaskID: "task-7",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateChild returned error: %v", err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sessions", "list"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("sessions list exit = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "Zero sessions") || !strings.Contains(output, root.SessionID) || !strings.Contains(output, child.SessionID) || !strings.Contains(output, "code-review") || !strings.Contains(output, "tag=specialist") || !strings.Contains(output, "depth=1") {
|
|
t.Fatalf("sessions list output = %q, want root, child, and agent", output)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sessions", "lineage", child.SessionID}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("sessions lineage exit = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, "root -> child") {
|
|
t.Fatalf("sessions lineage output = %q, want root-to-child path", got)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sessions", "tree", root.SessionID, "--json"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("sessions tree exit = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
var tree sessions.TreeNode
|
|
if err := json.Unmarshal(stdout.Bytes(), &tree); err != nil {
|
|
t.Fatalf("sessions tree JSON did not decode: %v\n%s", err, stdout.String())
|
|
}
|
|
if tree.Session.SessionID != root.SessionID || len(tree.Children) != 1 || tree.Children[0].Session.SessionID != child.SessionID {
|
|
t.Fatalf("sessions tree JSON = %#v, want root with one child", tree)
|
|
}
|
|
}
|
|
|
|
func TestRunSessionsPlansRewindAndCompaction(t *testing.T) {
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T19:30:00Z")})
|
|
session, err := store.Create(sessions.CreateInput{SessionID: "plan", Title: "Plan session"})
|
|
if err != nil {
|
|
t.Fatalf("Create returned error: %v", err)
|
|
}
|
|
for _, content := range []string{"alpha", "beta", "gamma", "delta"} {
|
|
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": content}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sessions", "rewind-plan", session.SessionID, "--sequence", "2", "--json"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("sessions rewind-plan exit = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
var rewind sessions.RewindPlan
|
|
if err := json.Unmarshal(stdout.Bytes(), &rewind); err != nil {
|
|
t.Fatalf("rewind-plan JSON did not decode: %v\n%s", err, stdout.String())
|
|
}
|
|
if rewind.TargetEventID != "plan:2" || rewind.KeptCount != 2 || rewind.DroppedCount != 2 {
|
|
t.Fatalf("unexpected rewind plan: %#v", rewind)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sessions", "compact-plan", session.SessionID, "--preserve-last", "1", "--max-prompt-chars", "500", "--json"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("sessions compact-plan exit = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
var compact sessions.CompactionPlan
|
|
if err := json.Unmarshal(stdout.Bytes(), &compact); err != nil {
|
|
t.Fatalf("compact-plan JSON did not decode: %v\n%s", err, stdout.String())
|
|
}
|
|
if compact.CompactableCount != 3 || compact.PreservedCount != 1 || !strings.Contains(compact.SummaryPrompt, "alpha") {
|
|
t.Fatalf("unexpected compaction plan: %#v", compact)
|
|
}
|
|
}
|
|
|
|
// Regression: `sessions rewind` must be reachable (the parser whitelist), and the
|
|
// destructive path must honor --exclude-target (apply BEFORE the target event).
|
|
func TestRunSessionsRewindIsReachableAndHonorsExcludeTarget(t *testing.T) {
|
|
makeStore := func() (*sessions.Store, string) {
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T19:30:00Z")})
|
|
session, err := store.Create(sessions.CreateInput{SessionID: "rw", Title: "Rewind", Cwd: t.TempDir()})
|
|
if err != nil {
|
|
t.Fatalf("Create returned error: %v", err)
|
|
}
|
|
for _, content := range []string{"alpha", "beta", "gamma", "delta"} {
|
|
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": content}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return store, session.SessionID
|
|
}
|
|
remainingAfterRewind := func(extra ...string) int {
|
|
store, id := makeStore()
|
|
var stdout, stderr bytes.Buffer
|
|
args := append([]string{"sessions", "rewind", id, "--sequence", "2"}, extra...)
|
|
if ec := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store { return store },
|
|
}); ec != exitSuccess {
|
|
t.Fatalf("sessions rewind %v exit = %d, stderr = %q", extra, ec, stderr.String())
|
|
}
|
|
events, err := store.ReadEvents(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return len(events)
|
|
}
|
|
|
|
keepTarget := remainingAfterRewind() // keep through seq 2
|
|
excludeTarget := remainingAfterRewind("--exclude-target") // keep through seq 1
|
|
if excludeTarget >= keepTarget {
|
|
t.Fatalf("--exclude-target should keep fewer events than keep-target: keep=%d exclude=%d", keepTarget, excludeTarget)
|
|
}
|
|
}
|
|
|
|
func TestRunSessionsValidatesArgs(t *testing.T) {
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T19:30:00Z")})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sessions", "children", "missing"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("sessions children exit = %d, want usage", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "Zero session not found: missing") {
|
|
t.Fatalf("sessions children stderr = %q, want missing-session error", stderr.String())
|
|
}
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
args []string
|
|
wantStderr string
|
|
}{
|
|
{name: "unknown command", args: []string{"sessions", "foo"}, wantStderr: `unknown sessions command "foo"`},
|
|
{name: "list extra arg", args: []string{"sessions", "list", "extra"}, wantStderr: "sessions list does not accept positional arguments"},
|
|
{name: "rewind flag on list", args: []string{"sessions", "list", "--sequence", "2"}, wantStderr: "only valid for sessions rewind-plan"},
|
|
{name: "compaction flag on tree", args: []string{"sessions", "tree", "root", "--preserve-last", "2"}, wantStderr: "only valid for sessions compact-plan"},
|
|
{name: "empty event flag", args: []string{"sessions", "list", "--event="}, wantStderr: "--event requires a value"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode := runWithDeps(test.args, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("%v exit = %d, want usage", test.args, exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), test.wantStderr) {
|
|
t.Fatalf("%v stderr = %q, want %q", test.args, stderr.String(), test.wantStderr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunSessionsListFiltersByKind(t *testing.T) {
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: sequenceClockCLI([]time.Time{
|
|
time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC),
|
|
time.Date(2026, 6, 8, 12, 0, 1, 0, time.UTC),
|
|
})})
|
|
if _, err := store.Create(sessions.CreateInput{SessionID: "regular", Title: "Regular"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.Create(sessions.CreateInput{
|
|
SessionID: "draft",
|
|
SessionKind: sessions.SessionKindSpecDraft,
|
|
Title: "Spec draft",
|
|
SpecID: "2026-06-08-spec-draft",
|
|
SpecStatus: sessions.SpecStatusDraft,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide, Title: "BTW side"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"sessions", "list", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("sessions list --kind exit = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "draft") || strings.Contains(output, "regular") || !strings.Contains(output, "spec=draft") {
|
|
t.Fatalf("filtered sessions output = %q", output)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sessions", "list", "--kind", "side"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitSuccess || !strings.Contains(stdout.String(), "side") || strings.Contains(stdout.String(), "regular") {
|
|
t.Fatalf("sessions list --kind side exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"sessions", "tree", "draft", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
|
|
newSessionStore: func() *sessions.Store {
|
|
return store
|
|
},
|
|
})
|
|
if exitCode != exitUsage || !strings.Contains(stderr.String(), "--kind is only valid for sessions list") {
|
|
t.Fatalf("expected --kind validation error, exit=%d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func sequenceClockCLI(values []time.Time) func() time.Time {
|
|
index := 0
|
|
return func() time.Time {
|
|
if index >= len(values) {
|
|
return values[len(values)-1]
|
|
}
|
|
value := values[index]
|
|
index++
|
|
return value
|
|
}
|
|
}
|