275 lines
9.4 KiB
Go
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)
|
|
}
|
|
}
|