| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "io" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/agent" |
| 15 | "reasonix/internal/config" |
| 16 | "reasonix/internal/event" |
| 17 | "reasonix/internal/jobs" |
| 18 | ) |
| 19 | |
| 20 | func TestTaskMachineListUsesContentFreePersistedMetadata(t *testing.T) { |
| 21 | identityKey := installMachineTestIdentity(t) |
| 22 | dir := t.TempDir() |
| 23 | saveMachineTestSession(t, dir, "session", time.Date(2026, 7, 23, 13, 0, 0, 0, time.UTC)) |
| 24 | path := filepath.Join(dir, "session.jsonl") |
| 25 | manager := jobs.NewManager(event.Discard) |
| 26 | manager.SetActiveSessionPath("session", path) |
| 27 | job := manager.StartForSession("session", "task", "PRIVATE TASK LABEL", func(context.Context, io.Writer) (string, error) { |
| 28 | return "PRIVATE TASK OUTPUT", nil |
| 29 | }) |
| 30 | manager.WaitForSession(context.Background(), "session", []string{job.ID}, 1) |
| 31 | manager.Close() |
| 32 | |
| 33 | var out bytes.Buffer |
| 34 | if code := runTaskCommand([]string{"list", "--json", "--dir", dir}, &out); code != 0 { |
| 35 | t.Fatalf("task list exit code = %d, output = %s", code, out.String()) |
| 36 | } |
| 37 | var response machineTaskList |
| 38 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 39 | t.Fatalf("decode task list: %v", err) |
| 40 | } |
| 41 | if len(response.Tasks) != 1 || response.Tasks[0].ID != job.ID || response.Tasks[0].Status != "done" { |
| 42 | t.Fatalf("tasks = %+v", response.Tasks) |
| 43 | } |
| 44 | if response.Tasks[0].Kind != "background" || response.Tasks[0].SessionID != machineSessionIDWithKey("session", identityKey) { |
| 45 | t.Fatalf("task projection = %+v", response.Tasks[0]) |
| 46 | } |
| 47 | if !response.Tasks[0].ArtifactComplete { |
| 48 | t.Fatalf("persisted task artifact should be complete: %+v", response.Tasks[0]) |
| 49 | } |
| 50 | if strings.Contains(out.String(), "PRIVATE") || strings.Contains(out.String(), dir) { |
| 51 | t.Fatalf("task output leaked private data: %s", out.String()) |
| 52 | } |
| 53 | |
| 54 | if err := os.Remove(filepath.Join(jobs.ArtifactDir(path), job.ID+".log")); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | out.Reset() |
| 58 | if code := runTaskCommand([]string{"list", "--json", "--dir", dir}, &out); code != 0 { |
| 59 | t.Fatalf("task list after artifact removal exit code = %d, output = %s", code, out.String()) |
| 60 | } |
| 61 | response = machineTaskList{} |
| 62 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 63 | t.Fatalf("decode task list after artifact removal: %v", err) |
| 64 | } |
| 65 | if len(response.Tasks) != 1 || response.Tasks[0].ArtifactComplete { |
| 66 | t.Fatalf("task projection after artifact removal = %+v", response.Tasks) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestTaskMachineProjectsSubagentLifecycleAndArtifactCompleteness(t *testing.T) { |
| 71 | identityKey := installMachineTestIdentity(t) |
| 72 | dir := t.TempDir() |
| 73 | saveMachineTestSession(t, dir, "session", time.Now()) |
| 74 | subDir := filepath.Join(dir, "subagents") |
| 75 | if err := os.MkdirAll(subDir, 0o700); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | now := time.Now().UTC() |
| 79 | metas := []agent.SubagentMeta{ |
| 80 | {Ref: "sa_running", CreatedAt: now, UpdatedAt: now, Status: agent.SubagentRunning, Kind: "task", ParentSession: "session"}, |
| 81 | {Ref: "sa_complete", CreatedAt: now.Add(-time.Minute), UpdatedAt: now, Status: agent.SubagentCompleted, Kind: "task", ParentSession: "session"}, |
| 82 | {Ref: "sa_missing", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now, Status: agent.SubagentCompleted, Kind: "task", ParentSession: "session"}, |
| 83 | } |
| 84 | for _, meta := range metas { |
| 85 | data, err := json.Marshal(meta) |
| 86 | if err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | if err := os.WriteFile(filepath.Join(subDir, meta.Ref+".meta.json"), data, 0o600); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | } |
| 93 | if err := os.WriteFile(filepath.Join(subDir, "sa_complete.jsonl"), []byte("persisted transcript\n"), 0o600); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | |
| 97 | tasks, err := machineTasks(dir, machineSessionIDWithKey("session", identityKey), identityKey) |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | byID := make(map[string]machineTask, len(tasks)) |
| 102 | for _, task := range tasks { |
| 103 | byID[task.ID] = task |
| 104 | } |
| 105 | if got := byID["sa_running"]; got.Status != string(agent.SubagentInterrupted) || got.FinishedAt != "" || got.ArtifactComplete { |
| 106 | t.Fatalf("stale running projection = %+v", got) |
| 107 | } |
| 108 | if got := byID["sa_complete"]; got.Status != string(agent.SubagentCompleted) || got.FinishedAt == "" || !got.ArtifactComplete { |
| 109 | t.Fatalf("completed projection = %+v", got) |
| 110 | } |
| 111 | if got := byID["sa_missing"]; got.FinishedAt == "" || got.ArtifactComplete { |
| 112 | t.Fatalf("missing artifact projection = %+v", got) |
| 113 | } |
| 114 | |
| 115 | lease, err := agent.TryAcquireSessionLease(filepath.Join(dir, "session.jsonl")) |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | defer lease.Release() |
| 120 | tasks, err = machineTasks(dir, machineSessionIDWithKey("session", identityKey), identityKey) |
| 121 | if err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | for _, task := range tasks { |
| 125 | if task.ID == "sa_running" && (task.Status != string(agent.SubagentRunning) || task.FinishedAt != "" || task.ArtifactComplete) { |
| 126 | t.Fatalf("live running projection = %+v", task) |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestTaskMachineProjectRootUsesProjectStore(t *testing.T) { |
| 132 | identityKey := installMachineTestIdentity(t) |
| 133 | projectRoot := t.TempDir() |
| 134 | sessionDir := config.ProjectSessionDir(projectRoot) |
| 135 | saveMachineTestSession(t, sessionDir, "session", time.Date(2026, 7, 23, 13, 30, 0, 0, time.UTC)) |
| 136 | path := filepath.Join(sessionDir, "session.jsonl") |
| 137 | manager := jobs.NewManager(event.Discard) |
| 138 | manager.SetActiveSessionPath("session", path) |
| 139 | job := manager.StartForSession("session", "task", "PRIVATE TASK LABEL", func(context.Context, io.Writer) (string, error) { |
| 140 | return "PRIVATE TASK OUTPUT", nil |
| 141 | }) |
| 142 | manager.WaitForSession(context.Background(), "session", []string{job.ID}, 1) |
| 143 | manager.Close() |
| 144 | |
| 145 | var out bytes.Buffer |
| 146 | if code := runTaskCommand([]string{"list", "--json", "--project-root", projectRoot}, &out); code != 0 { |
| 147 | t.Fatalf("task list exit code = %d, output = %s", code, out.String()) |
| 148 | } |
| 149 | var response machineTaskList |
| 150 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 151 | t.Fatalf("decode task list: %v", err) |
| 152 | } |
| 153 | if len(response.Tasks) != 1 || response.Tasks[0].ID != job.ID || response.Tasks[0].SessionID != machineSessionIDWithKey("session", identityKey) { |
| 154 | t.Fatalf("tasks = %+v, want project task", response.Tasks) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestTaskMachineShowRequiresNonZeroForMissingTask(t *testing.T) { |
| 159 | installMachineTestIdentity(t) |
| 160 | dir := t.TempDir() |
| 161 | var out bytes.Buffer |
| 162 | if code := runTaskCommand([]string{"show", "--json", "missing", "--dir", dir}, &out); code != 1 { |
| 163 | t.Fatalf("exit code = %d, output = %s", code, out.String()) |
| 164 | } |
| 165 | var response machineErrorResponse |
| 166 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 167 | t.Fatalf("decode error: %v", err) |
| 168 | } |
| 169 | if response.Error.Code != "task_not_found" { |
| 170 | t.Fatalf("response = %+v", response) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestTaskMachineEmptyListUsesAnArray(t *testing.T) { |
| 175 | installMachineTestIdentity(t) |
| 176 | var out bytes.Buffer |
| 177 | if code := runTaskCommand([]string{"list", "--json", "--dir", t.TempDir()}, &out); code != 0 { |
| 178 | t.Fatalf("task list exit code = %d, output = %s", code, out.String()) |
| 179 | } |
| 180 | var response machineTaskList |
| 181 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if response.Tasks == nil { |
| 185 | t.Fatalf("tasks must be [] in empty response: %s", out.String()) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestTaskMachineRejectsConflictingSessionSources(t *testing.T) { |
| 190 | installMachineTestIdentity(t) |
| 191 | dir := t.TempDir() |
| 192 | var out bytes.Buffer |
| 193 | if code := runTaskCommand([]string{"list", "--json", "--dir", dir, "--project-root", dir}, &out); code != 2 { |
| 194 | t.Fatalf("exit code = %d, output = %s", code, out.String()) |
| 195 | } |
| 196 | var response machineErrorResponse |
| 197 | if err := json.Unmarshal(out.Bytes(), &response); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if response.Error.Code != "invalid_argument" { |
| 201 | t.Fatalf("response = %+v", response) |
| 202 | } |
| 203 | } |
| 204 |