Files
2026-07-13 13:00:08 +08:00

275 lines
9.4 KiB
Go

package main
import (
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/jobs"
)
func TestProjectTreeShowsDetachedRuntimeStatus(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
topicID := "topic_detached_status"
topicTitle := "Detached status"
if err := setTopicTitle("", topicID, topicTitle); err != nil {
t.Fatalf("set topic title: %v", err)
}
path := writeTopicSessionWithPrompt(t, dir, "detached.jsonl", topicID, topicTitle, "", "detached prompt", time.Now())
app := NewApp()
sink := &tabEventSink{tabID: "detached", app: app}
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "detached", Sink: sink})
defer ctrl.Close()
app.detachedSessions[sessionRuntimeKey(path)] = &WorkspaceTab{
ID: "detached",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: topicID,
TopicTitle: topicTitle,
SessionPath: path,
Ctrl: ctrl,
Ready: true,
sink: sink,
disabledMCP: map[string]ServerView{},
}
ctrl.Submit("keep detached runtime running")
<-runner.started
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want one global topic", nodes)
}
topic := nodes[0].Children[0]
if topic.TopicID != topicID || topic.Status != topicStatusThinking || !topic.Running {
t.Fatalf("detached topic status = %+v, want thinking/running for %q", topic, topicID)
}
close(runner.release)
waitNotRunning(t, ctrl)
}
func TestProjectTreeSplitsMultipleRuntimeSessionsInSameTopic(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
topicID := "topic_multi_runtime_status"
topicTitle := "Multi runtime status"
if err := setTopicTitle("", topicID, topicTitle); err != nil {
t.Fatalf("set topic title: %v", err)
}
sessionA := writeTopicSessionWithPrompt(t, dir, "session-a.jsonl", topicID, topicTitle, "", "session A prompt", time.Now().Add(-time.Hour))
sessionB := writeTopicSessionWithPrompt(t, dir, "session-b.jsonl", topicID, topicTitle, "", "session B prompt", time.Now())
app := NewApp()
runnerA := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
runnerB := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
ctrlA := control.New(control.Options{Runner: runnerA, SessionDir: dir, SessionPath: sessionA, Label: "a", Sink: event.Discard})
ctrlB := control.New(control.Options{Runner: runnerB, SessionDir: dir, SessionPath: sessionB, Label: "b", Sink: event.Discard})
defer ctrlA.Close()
defer ctrlB.Close()
detached := &WorkspaceTab{
ID: detachedRuntimeTabID(sessionRuntimeKey(sessionA)),
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: topicID,
TopicTitle: topicTitle,
SessionPath: sessionA,
Ctrl: ctrlA,
Ready: true,
ActivityStatus: topicStatusWaitingConfirmation,
disabledMCP: map[string]ServerView{},
}
visible := &WorkspaceTab{
ID: "visible",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: topicID,
TopicTitle: topicTitle,
SessionPath: sessionB,
Ctrl: ctrlB,
Ready: true,
ActivityStatus: topicStatusThinking,
disabledMCP: map[string]ServerView{},
}
app.detachedSessions[sessionRuntimeKey(sessionA)] = detached
app.tabs[visible.ID] = visible
app.tabOrder = []string{visible.ID}
app.activeTabID = visible.ID
ctrlA.Submit("block A")
ctrlB.Submit("block B")
<-runnerA.started
<-runnerB.started
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want one global topic", nodes)
}
topic := nodes[0].Children[0]
if topic.Status != "" || topic.Running {
t.Fatalf("topic should not merge child runtime statuses: %+v", topic)
}
if len(topic.Children) != 2 {
t.Fatalf("topic children = %#v, want two session runtime rows", topic.Children)
}
statusByPath := map[string]string{}
for _, child := range topic.Children {
statusByPath[filepath.Clean(child.SessionPath)] = child.Status
}
if statusByPath[filepath.Clean(sessionA)] != topicStatusWaitingConfirmation {
t.Fatalf("session A status = %q, want waiting; children=%#v", statusByPath[filepath.Clean(sessionA)], topic.Children)
}
if statusByPath[filepath.Clean(sessionB)] != topicStatusThinking {
t.Fatalf("session B status = %q, want thinking; children=%#v", statusByPath[filepath.Clean(sessionB)], topic.Children)
}
close(runnerA.release)
close(runnerB.release)
waitNotRunning(t, ctrlA)
waitNotRunning(t, ctrlB)
}
func TestProjectTreeShowsBackgroundJobStatus(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
topicID := "topic_background_job"
topicTitle := "Background job"
if err := setTopicTitle("", topicID, topicTitle); err != nil {
t.Fatalf("set topic title: %v", err)
}
path := writeTopicSessionWithPrompt(t, dir, "job.jsonl", topicID, topicTitle, "", "job prompt", time.Now())
jm := jobs.NewManager(event.Discard)
release := make(chan struct{})
jm.StartForSession(agent.BranchID(path), "bash", "sleep", func(ctx context.Context, _ io.Writer) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-release:
return "", nil
}
})
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "job", Jobs: jm})
defer ctrl.Close()
app := NewApp()
app.tabs["job"] = &WorkspaceTab{
ID: "job",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: topicID,
TopicTitle: topicTitle,
SessionPath: path,
Ctrl: ctrl,
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabOrder = []string{"job"}
app.activeTabID = "job"
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want one global topic", nodes)
}
topic := nodes[0].Children[0]
if topic.Status != topicStatusBackgroundJob || !topic.Running {
t.Fatalf("background job topic status = %+v, want background_job/running", topic)
}
tabs := app.ListTabs()
if len(tabs) != 1 {
t.Fatalf("tabs = %d, want 1", len(tabs))
}
if !tabs[0].Running || tabs[0].PendingPrompt || tabs[0].Cancellable || tabs[0].BackgroundJobs != 1 {
t.Fatalf("tab runtime = running:%v pending:%v cancellable:%v background:%d, want background-only running tab", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].BackgroundJobs)
}
raw, err := json.Marshal(tabs[0])
if err != nil {
t.Fatalf("marshal tab meta: %v", err)
}
if !strings.Contains(string(raw), `"cancellable":false`) {
t.Fatalf("tab metadata should serialize explicit cancellable=false: %s", raw)
}
close(release)
waitNoJobs(t, ctrl)
nodes = app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
t.Fatalf("project tree after job finish = %#v, want one global topic", nodes)
}
topic = nodes[0].Children[0]
if topic.Status != "" || topic.Running {
t.Fatalf("background job topic status after finish = %+v, want idle", topic)
}
}
func TestBackgroundJobNoticeForcesProjectTreeRefresh(t *testing.T) {
isolateDesktopUserDirs(t)
var refreshes int32
app := NewApp()
app.projectTreeChangedHook = func() {
atomic.AddInt32(&refreshes, 1)
}
app.tabs["job"] = &WorkspaceTab{
ID: "job",
Scope: "global",
TopicID: "topic_background_notice",
Ready: true,
disabledMCP: map[string]ServerView{},
}
sink := &tabEventSink{tabID: "job", app: app}
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "background bash finished: bash-1"})
if got := atomic.LoadInt32(&refreshes); got != 1 {
t.Fatalf("project-tree refreshes = %d, want 1 for background job finish notice", got)
}
}
func waitNoJobs(t *testing.T, ctrl control.SessionAPI) {
t.Helper()
deadline := time.Now().Add(time.Second)
for len(ctrl.Jobs()) > 0 {
if time.Now().After(deadline) {
t.Fatalf("controller still has jobs: %+v", ctrl.Jobs())
}
time.Sleep(10 * time.Millisecond)
}
}
func TestTopicActivityStatusPresentsReadinessAsPaused(t *testing.T) {
readiness := event.Event{
Kind: event.TurnDone,
Err: &agent.FinalReadinessError{Attempts: 3, Reason: "missing verification"},
Outcome: event.TurnOutcomeFinalReadiness,
}
if status, ok := topicActivityStatusFromEvent(readiness); !ok || status != topicStatusPaused {
t.Fatalf("readiness turn end = (%q, %v), want (%q, true)", status, ok, topicStatusPaused)
}
if status, ok := topicActivityStatusFromEvent(event.Event{Kind: event.TurnDone, Err: io.EOF}); !ok || status != topicStatusError {
t.Fatalf("ordinary turn error = (%q, %v), want (%q, true)", status, ok, topicStatusError)
}
if status, ok := topicActivityStatusFromEvent(event.Event{Kind: event.TurnDone}); !ok || status != "" {
t.Fatalf("clean turn end = (%q, %v), want cleared status", status, ok)
}
}