package main import ( "encoding/json" "fmt" "os" "path/filepath" "testing" "time" ) func writeJSONL(t *testing.T, path string, messages []JSONLMessage) { t.Helper() f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() enc := json.NewEncoder(f) for _, msg := range messages { if err := enc.Encode(msg); err != nil { t.Fatal(err) } } } func makeMessages(n int) []JSONLMessage { msgs := make([]JSONLMessage, n) for i := range msgs { msgs[i] = JSONLMessage{ Type: "assistant", Timestamp: time.Now().UTC().Format(time.RFC3339), SessionID: "test-session", Cwd: "/tmp", GitBranch: "main", Message: &MessagePayload{ Role: "assistant", Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("line %d", i+1)}}, StopReason: "end_turn", }, } } return msgs } func TestTailReadJSONL(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.jsonl") writeJSONL(t, path, makeMessages(50)) msgs, err := TailReadJSONL(path, 3) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(msgs) != 3 { t.Fatalf("expected 3 messages, got %d", len(msgs)) } // Should be the last 3 (chronological order: 48, 49, 50) if msgs[0].Message.Content[0].Text != "line 48" { t.Errorf("first message text = %q, want %q", msgs[0].Message.Content[0].Text, "line 48") } if msgs[2].Message.Content[0].Text != "line 50" { t.Errorf("last message text = %q, want %q", msgs[2].Message.Content[0].Text, "line 50") } } func TestTailReadJSONL_TruncatedLastLine(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.jsonl") // Write 5 complete lines then a truncated line (no trailing \n) writeJSONL(t, path, makeMessages(5)) // Append truncated JSON (no newline) f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Fatal(err) } f.WriteString(`{"type":"assistant","timestamp":"2026-01-01T00:00:00Z"`) f.Close() msgs, err := TailReadJSONL(path, 3) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(msgs) != 3 { t.Fatalf("expected 3 messages (truncated ignored), got %d", len(msgs)) } // Last complete line should be line 5 if msgs[2].Message.Content[0].Text != "line 5" { t.Errorf("last message text = %q, want %q", msgs[2].Message.Content[0].Text, "line 5") } } func TestTailReadJSONL_EmptyFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "empty.jsonl") os.WriteFile(path, []byte{}, 0o644) msgs, err := TailReadJSONL(path, 3) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(msgs) != 0 { t.Errorf("expected 0 messages for empty file, got %d", len(msgs)) } } func TestTailReadJSONL_SmallFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "small.jsonl") writeJSONL(t, path, makeMessages(2)) msgs, err := TailReadJSONL(path, 10) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(msgs) != 2 { t.Fatalf("expected 2 messages, got %d", len(msgs)) } } func TestFindSessionForProcess(t *testing.T) { dir := t.TempDir() cwd := "/home/user/my-project" encoded := EncodePath(cwd) projectDir := filepath.Join(dir, encoded) os.MkdirAll(projectDir, 0o755) // Create two JSONL files with different mtimes older := filepath.Join(projectDir, "old-session.jsonl") newer := filepath.Join(projectDir, "new-session.jsonl") olderMsgs := []JSONLMessage{{ Type: "assistant", Timestamp: "2026-03-22T10:00:00Z", SessionID: "old-id", Cwd: cwd, GitBranch: "old-branch", Message: &MessagePayload{Role: "assistant", Content: []ContentBlock{{Type: "text", Text: "old"}}, StopReason: "end_turn"}, }} newerMsgs := []JSONLMessage{{ Type: "assistant", Timestamp: "2026-03-23T12:00:00Z", SessionID: "new-id", Cwd: cwd, GitBranch: "feat-x", Message: &MessagePayload{Role: "assistant", Content: []ContentBlock{{Type: "text", Text: "new"}}, StopReason: "end_turn"}, }} writeJSONL(t, older, olderMsgs) // Ensure newer has a later mtime time.Sleep(10 * time.Millisecond) writeJSONL(t, newer, newerMsgs) proc := Process{PID: 42, Cwd: cwd} jsonlPath, msgs, err := FindSessionForProcess(dir, proc) if err != nil { t.Fatalf("unexpected error: %v", err) } if jsonlPath != newer { t.Errorf("expected newest JSONL %q, got %q", newer, jsonlPath) } if len(msgs) == 0 { t.Fatal("expected at least 1 message") } if msgs[0].SessionID != "new-id" { t.Errorf("sessionID = %q, want %q", msgs[0].SessionID, "new-id") } } func TestFindSessionForProcess_NoMatch(t *testing.T) { dir := t.TempDir() proc := Process{PID: 42, Cwd: "/nonexistent/path"} _, _, err := FindSessionForProcess(dir, proc) if err == nil { t.Fatal("expected error for no matching JSONL, got nil") } } func TestFindSessionForProcess_ExcludesSubagents(t *testing.T) { dir := t.TempDir() cwd := "/home/user/project" encoded := EncodePath(cwd) projectDir := filepath.Join(dir, encoded) os.MkdirAll(projectDir, 0o755) // Create a main session JSONL mainJSONL := filepath.Join(projectDir, "main-session.jsonl") writeJSONL(t, mainJSONL, []JSONLMessage{{ Type: "assistant", Timestamp: "2026-03-23T12:00:00Z", SessionID: "main-id", Cwd: cwd, GitBranch: "main", Message: &MessagePayload{Role: "assistant", Content: []ContentBlock{{Type: "text", Text: "main"}}, StopReason: "end_turn"}, }}) // Create a subagent JSONL in a subdirectory (should be excluded) subDir := filepath.Join(projectDir, "some-uuid", "subagents") os.MkdirAll(subDir, 0o755) subJSONL := filepath.Join(subDir, "agent-1.jsonl") writeJSONL(t, subJSONL, []JSONLMessage{{ Type: "assistant", Timestamp: "2026-03-23T13:00:00Z", SessionID: "sub-id", Cwd: cwd, GitBranch: "main", Message: &MessagePayload{Role: "assistant", Content: []ContentBlock{{Type: "text", Text: "sub"}}, StopReason: "end_turn"}, }}) proc := Process{PID: 42, Cwd: cwd} jsonlPath, msgs, err := FindSessionForProcess(dir, proc) if err != nil { t.Fatalf("unexpected error: %v", err) } if jsonlPath != mainJSONL { t.Errorf("expected main JSONL %q, got %q", mainJSONL, jsonlPath) } if msgs[0].SessionID != "main-id" { t.Errorf("should use main session, got sessionID=%q", msgs[0].SessionID) } }